smartystreets_ruby_sdk 6.4.1 → 10.0.0

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.json +18 -0
  3. data/.github/workflows/ci.yml +10 -0
  4. data/.github/workflows/publish.yml +29 -0
  5. data/.github/workflows/test.yml +21 -0
  6. data/CLAUDE.md +73 -0
  7. data/Makefile +11 -5
  8. data/README.md +2 -0
  9. data/examples/international_autocomplete_example.rb +10 -7
  10. data/examples/international_example.rb +7 -6
  11. data/examples/international_postal_code_example.rb +5 -4
  12. data/examples/us_autocomplete_example.rb +69 -0
  13. data/examples/us_autocomplete_pro_example.rb +10 -12
  14. data/examples/us_enrichment_business_example.rb +72 -0
  15. data/examples/us_enrichment_business_name_search_example.rb +76 -0
  16. data/examples/us_enrichment_etag_example.rb +130 -0
  17. data/examples/us_enrichment_example.rb +130 -44
  18. data/examples/us_extract_example.rb +7 -6
  19. data/examples/us_reverse_geo_example.rb +10 -6
  20. data/examples/us_street_component_analysis_example.rb +2 -1
  21. data/examples/us_street_iana_timezone_example.rb +54 -0
  22. data/examples/us_street_match_strategy_example.rb +85 -0
  23. data/examples/us_street_multiple_address_example.rb +7 -6
  24. data/examples/us_street_single_address_example.rb +7 -6
  25. data/examples/us_zipcode_multiple_lookup_example.rb +7 -6
  26. data/examples/us_zipcode_single_lookup_example.rb +7 -6
  27. data/lib/smartystreets_ruby_sdk/basic_auth_credentials.rb +14 -0
  28. data/lib/smartystreets_ruby_sdk/client_builder.rb +36 -5
  29. data/lib/smartystreets_ruby_sdk/custom_header_sender.rb +16 -2
  30. data/lib/smartystreets_ruby_sdk/errors.rb +16 -11
  31. data/lib/smartystreets_ruby_sdk/exceptions.rb +12 -0
  32. data/lib/smartystreets_ruby_sdk/international_autocomplete/client.rb +2 -0
  33. data/lib/smartystreets_ruby_sdk/international_autocomplete/lookup.rb +4 -2
  34. data/lib/smartystreets_ruby_sdk/international_street/client.rb +10 -1
  35. data/lib/smartystreets_ruby_sdk/international_street/components.rb +8 -3
  36. data/lib/smartystreets_ruby_sdk/international_street/language_mode.rb +20 -3
  37. data/lib/smartystreets_ruby_sdk/international_street/lookup.rb +5 -12
  38. data/lib/smartystreets_ruby_sdk/international_street/rootlevel.rb +1 -5
  39. data/lib/smartystreets_ruby_sdk/native_sender.rb +3 -4
  40. data/lib/smartystreets_ruby_sdk/native_serializer.rb +1 -0
  41. data/lib/smartystreets_ruby_sdk/request.rb +2 -1
  42. data/lib/smartystreets_ruby_sdk/response.rb +13 -0
  43. data/lib/smartystreets_ruby_sdk/retry_sender.rb +32 -13
  44. data/lib/smartystreets_ruby_sdk/status_code_sender.rb +39 -22
  45. data/lib/smartystreets_ruby_sdk/us_autocomplete/client.rb +82 -0
  46. data/lib/smartystreets_ruby_sdk/us_autocomplete/geolocation_type.rb +8 -0
  47. data/lib/smartystreets_ruby_sdk/us_autocomplete/lookup.rb +73 -0
  48. data/lib/smartystreets_ruby_sdk/us_autocomplete/source_type.rb +8 -0
  49. data/lib/smartystreets_ruby_sdk/us_autocomplete/suggestion.rb +22 -0
  50. data/lib/smartystreets_ruby_sdk/us_autocomplete.rb +10 -0
  51. data/lib/smartystreets_ruby_sdk/us_autocomplete_pro/source_type.rb +8 -0
  52. data/lib/smartystreets_ruby_sdk/us_autocomplete_pro.rb +1 -0
  53. data/lib/smartystreets_ruby_sdk/us_enrichment/business/detail/attributes.rb +241 -0
  54. data/lib/smartystreets_ruby_sdk/us_enrichment/business/detail/response.rb +20 -0
  55. data/lib/smartystreets_ruby_sdk/us_enrichment/business/summary/business_entry.rb +16 -0
  56. data/lib/smartystreets_ruby_sdk/us_enrichment/business/summary/response.rb +24 -0
  57. data/lib/smartystreets_ruby_sdk/us_enrichment/client.rb +99 -30
  58. data/lib/smartystreets_ruby_sdk/us_enrichment/geo_reference/response.rb +2 -3
  59. data/lib/smartystreets_ruby_sdk/us_enrichment/lookup.rb +42 -9
  60. data/lib/smartystreets_ruby_sdk/us_enrichment/property/principal/response.rb +3 -4
  61. data/lib/smartystreets_ruby_sdk/us_enrichment/secondary/count/response.rb +2 -3
  62. data/lib/smartystreets_ruby_sdk/us_enrichment/secondary/response.rb +2 -3
  63. data/lib/smartystreets_ruby_sdk/us_enrichment.rb +6 -5
  64. data/lib/smartystreets_ruby_sdk/us_reverse_geo/source_type.rb +8 -0
  65. data/lib/smartystreets_ruby_sdk/us_reverse_geo/us_reverse_geo_response.rb +1 -1
  66. data/lib/smartystreets_ruby_sdk/us_reverse_geo.rb +1 -0
  67. data/lib/smartystreets_ruby_sdk/us_street/candidate.rb +2 -1
  68. data/lib/smartystreets_ruby_sdk/us_street/client.rb +10 -3
  69. data/lib/smartystreets_ruby_sdk/us_street/metadata.rb +5 -1
  70. data/lib/smartystreets_ruby_sdk/version.rb +1 -1
  71. data/lib/smartystreets_ruby_sdk.rb +2 -0
  72. metadata +27 -6
  73. data/.github/workflows/gem-publish.yml +0 -27
  74. data/lib/smartystreets_ruby_sdk/us_enrichment/risk/attributes.rb +0 -524
  75. data/lib/smartystreets_ruby_sdk/us_enrichment/risk/response.rb +0 -19
@@ -1,5 +1,6 @@
1
1
  require '../lib/smartystreets_ruby_sdk/static_credentials'
2
2
  require '../lib/smartystreets_ruby_sdk/shared_credentials'
3
+ require '../lib/smartystreets_ruby_sdk/basic_auth_credentials'
3
4
  require '../lib/smartystreets_ruby_sdk/client_builder'
4
5
  require '../lib/smartystreets_ruby_sdk/us_zipcode/lookup'
5
6
 
@@ -8,13 +9,13 @@ class UsZipcodeSingleLookupExample
8
9
  # key = 'Your SmartyStreets Auth Key here'
9
10
  # referer = 'Your host name here'
10
11
  # We recommend storing your secret keys in environment variables instead---it's safer!
11
- key = ENV['SMARTY_AUTH_WEB']
12
- referer = ENV['SMARTY_AUTH_REFERER']
13
- credentials = SmartyStreets::SharedCredentials.new(key, referer)
12
+ # key = ENV['SMARTY_AUTH_WEB']
13
+ # referer = ENV['SMARTY_AUTH_REFERER']
14
+ # credentials = SmartyStreets::SharedCredentials.new(key, referer)
14
15
 
15
- # id = ENV['SMARTY_AUTH_ID']
16
- # token = ENV['SMARTY_AUTH_TOKEN']
17
- # credentials = SmartyStreets::StaticCredentials.new(id, token)
16
+ id = ENV['SMARTY_AUTH_ID']
17
+ token = ENV['SMARTY_AUTH_TOKEN']
18
+ credentials = SmartyStreets::BasicAuthCredentials.new(id, token)
18
19
 
19
20
  client = SmartyStreets::ClientBuilder.new(credentials).build_us_zipcode_api_client
20
21
 
@@ -0,0 +1,14 @@
1
+ module SmartyStreets
2
+ class BasicAuthCredentials
3
+ def initialize(auth_id, auth_token)
4
+ raise ArgumentError, 'credentials (auth id, auth token) required' if auth_id.nil? || auth_id.empty? || auth_token.nil? || auth_token.empty?
5
+
6
+ @auth_id = auth_id
7
+ @auth_token = auth_token
8
+ end
9
+
10
+ def sign(request)
11
+ request.basic_auth = [@auth_id, @auth_token]
12
+ end
13
+ end
14
+ end
@@ -17,6 +17,7 @@ require_relative 'international_street/client'
17
17
  require_relative 'international_autocomplete/client'
18
18
  require_relative 'us_reverse_geo/client'
19
19
  require_relative 'us_autocomplete_pro/client'
20
+ require_relative 'us_autocomplete/client'
20
21
  require_relative 'us_enrichment/client'
21
22
  require_relative 'international_postal_code/client'
22
23
 
@@ -28,6 +29,7 @@ module SmartyStreets
28
29
  INTERNATIONAL_STREET_API_URL = 'https://international-street.api.smarty.com/verify'.freeze
29
30
  INTERNATIONAL_AUTOCOMPLETE_API_URL = "https://international-autocomplete.api.smarty.com/v2/lookup".freeze
30
31
  US_AUTOCOMPLETE_PRO_API_URL = 'https://us-autocomplete-pro.api.smarty.com/lookup'.freeze
32
+ US_AUTOCOMPLETE_API_URL = 'https://us-autocomplete.api.smarty.com/v2/lookup'.freeze
31
33
  US_EXTRACT_API_URL = 'https://us-extract.api.smarty.com/'.freeze
32
34
  US_STREET_API_URL = 'https://us-street.api.smarty.com/street-address'.freeze
33
35
  US_ZIP_CODE_API_URL = 'https://us-zipcode.api.smarty.com/lookup'.freeze
@@ -44,6 +46,7 @@ module SmartyStreets
44
46
  @url_prefix = nil
45
47
  @proxy = nil
46
48
  @header = nil
49
+ @append_headers = {}
47
50
  @licenses = %w()
48
51
  @debug = nil
49
52
  @queries = {}
@@ -65,7 +68,7 @@ module SmartyStreets
65
68
  self
66
69
  end
67
70
 
68
- # Default is a series of nested senders. (See build_sender()
71
+ # Sets the innermost HTTP transport sender while keeping the full middleware chain intact.
69
72
  #
70
73
  # Returns self to accommodate method chaining.
71
74
  def with_sender(sender)
@@ -105,6 +108,18 @@ module SmartyStreets
105
108
  # Returns self to accommodate method chaining.
106
109
  def with_custom_headers(header)
107
110
  @header = header
111
+ @append_headers = {}
112
+ self
113
+ end
114
+
115
+ # Appends the provided value to the existing header value using the specified separator,
116
+ # rather than adding a separate header value. This is useful for single-value headers like User-Agent.
117
+ #
118
+ # Returns self to accommodate method chaining.
119
+ def with_appended_header(key, value, separator)
120
+ @header = {} if @header.nil?
121
+ @append_headers[key] = separator
122
+ @header[key] = (@header[key] || []).concat([value])
108
123
  self
109
124
  end
110
125
 
@@ -141,6 +156,12 @@ module SmartyStreets
141
156
  self
142
157
  end
143
158
 
159
+ # with_feature_iana_time_zone turns on the IANA timezone feature for the request.
160
+ def with_feature_iana_time_zone()
161
+ self.with_custom_comma_separated_query("features", "iana-timezone")
162
+ self
163
+ end
164
+
144
165
  # Enables debug mode, which will print information about the HTTP request and response to $stdout.
145
166
  #
146
167
  # Returns self to accommodate method chaining.
@@ -166,6 +187,11 @@ module SmartyStreets
166
187
  USAutocompletePro::Client.new(build_sender, @serializer)
167
188
  end
168
189
 
190
+ def build_us_autocomplete_api_client
191
+ ensure_url_prefix_not_null(US_AUTOCOMPLETE_API_URL)
192
+ USAutocomplete::Client.new(build_sender, @serializer)
193
+ end
194
+
169
195
  def build_us_extract_api_client
170
196
  ensure_url_prefix_not_null(US_EXTRACT_API_URL)
171
197
  USExtract::Client.new(build_sender, @serializer)
@@ -199,13 +225,18 @@ module SmartyStreets
199
225
  # </editor-fold>
200
226
 
201
227
  def build_sender
202
- return @http_sender unless @http_sender.nil?
203
-
204
- sender = NativeSender.new(@max_timeout, @proxy, @debug)
228
+ if @http_sender
229
+ conflicts = []
230
+ conflicts << 'with_max_timeout' if @max_timeout != 10
231
+ conflicts << 'with_proxy' if @proxy
232
+ conflicts << 'with_debug' if @debug
233
+ raise ArgumentError, "with_sender cannot be combined with: #{conflicts.join(', ')}. These options only apply to the built-in HTTP transport." unless conflicts.empty?
234
+ end
235
+ sender = @http_sender || NativeSender.new(@max_timeout, @proxy, @debug)
205
236
 
206
237
  sender = StatusCodeSender.new(sender)
207
238
 
208
- sender = CustomHeaderSender.new(sender, @header) unless @header.nil?
239
+ sender = CustomHeaderSender.new(sender, @header, @append_headers) unless @header.nil?
209
240
 
210
241
  sender = SigningSender.new(@signer, sender) unless @signer.nil?
211
242
 
@@ -1,13 +1,27 @@
1
1
  module SmartyStreets
2
2
  class CustomHeaderSender
3
- def initialize(inner, header)
3
+ def initialize(inner, header, append_headers = {})
4
4
  @inner = inner
5
5
  @header = header
6
+ @append_headers = append_headers || {}
6
7
  end
7
8
 
8
9
  def send(request)
9
- request.header = @header
10
+ apply_headers.each { |key, value| request.header[key] = value }
10
11
  @inner.send(request)
11
12
  end
13
+
14
+ def apply_headers
15
+ result = {}
16
+ @header.each do |key, values|
17
+ if @append_headers.key?(key)
18
+ separator = @append_headers[key]
19
+ result[key] = values.respond_to?(:join) ? values.join(separator) : values.to_s
20
+ else
21
+ result[key] = values.dup
22
+ end
23
+ end
24
+ result
25
+ end
12
26
  end
13
27
  end
@@ -1,28 +1,33 @@
1
1
  module SmartyStreets
2
- NOT_MODIFIED = 'Not Modified: This data has not been modified since the last request. Please remove the etag header to fetch this data'.freeze
2
+ NOT_MODIFIED = 'Not Modified: The requested record has not been modified since the previous request' \
3
+ ' with the Etag value.'.freeze
3
4
 
4
- BAD_CREDENTIALS = 'Unauthorized: The credentials were provided incorrectly or did not match any existing,
5
- active credentials.'.freeze
5
+ BAD_CREDENTIALS = 'Unauthorized: The credentials were provided incorrectly or did not match any existing,' \
6
+ ' active credentials.'.freeze
6
7
 
7
- PAYMENT_REQUIRED = 'Payment Required: There is no active subscription
8
- for the account associated with the credentials submitted with the request.'.freeze
8
+ PAYMENT_REQUIRED = 'Payment Required: There is no active subscription for the account associated with the' \
9
+ ' credentials submitted with the request.'.freeze
9
10
 
10
- FORBIDDEN = 'Because the international service is currently in a limited release phase, only approved accounts' \
11
- ' may access the service.'.freeze
11
+ FORBIDDEN = 'Forbidden: The request contained valid data and was understood by the server, but the server' \
12
+ ' is refusing action.'.freeze
13
+
14
+ REQUEST_TIMEOUT = 'Request timeout error.'.freeze
12
15
 
13
16
  REQUEST_ENTITY_TOO_LARGE = 'Request Entity Too Large: The request body has exceeded the maximum size.'.freeze
14
17
 
15
- BAD_REQUEST = 'Bad Request (Malformed Payload): A GET request lacked a street field or the request body of a
16
- POST request contained malformed JSON, possibly because a value was submitted as a number rather than as a string.'.freeze
18
+ BAD_REQUEST = 'Bad Request (Malformed Payload): A GET request lacked a required field or the request body' \
19
+ ' of a POST request contained malformed JSON.'.freeze
17
20
 
18
21
  UNPROCESSABLE_ENTITY = 'GET request lacked required fields.'.freeze
19
22
 
20
- TOO_MANY_REQUESTS = 'The rate limit has been exceeded.'.freeze
23
+ TOO_MANY_REQUESTS = 'Too Many Requests: The rate limit for your account has been exceeded.'.freeze
21
24
 
22
25
  INTERNAL_SERVER_ERROR = 'Internal Server Error.'.freeze
23
26
 
27
+ BAD_GATEWAY = 'Bad Gateway error.'.freeze
28
+
24
29
  SERVICE_UNAVAILABLE = 'Service Unavailable. Try again later.'.freeze
25
30
 
26
31
  GATEWAY_TIMEOUT = 'The upstream data provider did not respond in a timely fashion and the request failed. ' \
27
- 'A serious, yet rare occurrence indeed.'.freeze
32
+ 'A serious, yet rare occurrence indeed.'.freeze
28
33
  end
@@ -4,6 +4,12 @@ module SmartyStreets
4
4
  end
5
5
 
6
6
  class NotModifiedInfo < SmartyError
7
+ attr_reader :response_etag
8
+
9
+ def initialize(message = nil, response_etag = nil)
10
+ super(message)
11
+ @response_etag = response_etag
12
+ end
7
13
  end
8
14
 
9
15
  class BadCredentialsError < SmartyError
@@ -12,6 +18,12 @@ module SmartyStreets
12
18
  class ForbiddenError < SmartyError
13
19
  end
14
20
 
21
+ class RequestTimeoutError < SmartyError
22
+ end
23
+
24
+ class BadGatewayError < SmartyError
25
+ end
26
+
15
27
  class PaymentRequiredError < SmartyError
16
28
  end
17
29
 
@@ -39,6 +39,8 @@ module SmartyStreets
39
39
  add_parameter(request, 'search', lookup.search)
40
40
  add_parameter(request, 'country', lookup.country)
41
41
  add_parameter(request, 'max_results', lookup.max_results.to_s)
42
+ add_parameter(request, 'max_group_results', lookup.max_group_results.to_s)
43
+ add_parameter(request, 'geolocation', 'on') if lookup.geolocation
42
44
  add_parameter(request, 'include_only_locality', lookup.locality)
43
45
  add_parameter(request, 'include_only_postal_code', lookup.postal_code)
44
46
 
@@ -6,14 +6,16 @@ module SmartyStreets
6
6
  # of the lookup after it comes back from the API.
7
7
  class Lookup < JSONAble
8
8
 
9
- attr_accessor :result, :search, :address_id, :country, :max_results, :locality, :postal_code, :custom_param_hash
9
+ attr_accessor :result, :search, :address_id, :country, :max_results, :max_group_results, :geolocation, :locality, :postal_code, :custom_param_hash
10
10
 
11
- def initialize(search = nil, address_id = nil, country = nil, max_results = nil, locality = nil, postal_code = nil, custom_param_hash = nil)
11
+ def initialize(search = nil, address_id = nil, country = nil, max_results = nil, max_group_results = 100, geolocation = false, locality = nil, postal_code = nil, custom_param_hash = nil)
12
12
  @result = []
13
13
  @search = search
14
14
  @address_id = address_id
15
15
  @country = country
16
16
  @max_results = max_results
17
+ @max_group_results = max_group_results
18
+ @geolocation = geolocation
17
19
  @locality = locality
18
20
  @postal_code = postal_code
19
21
  @custom_param_hash = {}
@@ -1,5 +1,6 @@
1
1
  require_relative '../request'
2
2
  require_relative 'candidate'
3
+ require_relative 'language_mode'
3
4
 
4
5
  module SmartyStreets
5
6
  module InternationalStreet
@@ -13,6 +14,7 @@ module SmartyStreets
13
14
  # Sends a Lookup object to the International Street API and stores the result in the Lookup's result field.
14
15
  # Deprecated, please use send_lookup instead.
15
16
  def send(lookup)
17
+ raise ArgumentError, 'lookup cannot be nil' if lookup.nil?
16
18
  lookup.ensure_enough_info
17
19
  request = build_request(lookup)
18
20
 
@@ -26,6 +28,7 @@ module SmartyStreets
26
28
 
27
29
  # Sends a Lookup object to the International Street API and stores the result in the Lookup's result field.
28
30
  def send_lookup(lookup)
31
+ raise ArgumentError, 'lookup cannot be nil' if lookup.nil?
29
32
  lookup.ensure_enough_info
30
33
  request = build_request(lookup)
31
34
 
@@ -43,7 +46,7 @@ module SmartyStreets
43
46
  add_parameter(request, 'input_id', lookup.input_id)
44
47
  add_parameter(request, 'country', lookup.country)
45
48
  add_parameter(request, 'geocode', lookup.geocode.to_s)
46
- add_parameter(request, 'language', lookup.language)
49
+ add_parameter(request, 'language', normalized_language(lookup.language))
47
50
  add_parameter(request, 'freeform', lookup.freeform)
48
51
  add_parameter(request, 'address1', lookup.address1)
49
52
  add_parameter(request, 'address2', lookup.address2)
@@ -66,6 +69,12 @@ module SmartyStreets
66
69
  request.parameters[key] = value unless value.nil? or value.empty?
67
70
  end
68
71
 
72
+ def normalized_language(language)
73
+ return nil if language.nil?
74
+
75
+ LanguageMode.from_value(language).value
76
+ end
77
+
69
78
  def convert_candidates(raw_candidates)
70
79
  candidates = []
71
80
 
@@ -5,11 +5,12 @@ module SmartyStreets
5
5
  attr_reader :premise, :thoroughfare_trailing_type, :sub_building, :locality, :post_box_number,
6
6
  :thoroughfare_name, :thoroughfare_postdirection, :dependent_thoroughfare, :premise_prefix_number,
7
7
  :thoroughfare, :dependent_thoroughfare_name, :postal_code_short, :dependent_thoroughfare_trailing_type,
8
- :administrative_area, :administrative_area_iso2,:administrative_area_short, :administrative_area_long, :post_box,
8
+ :administrative_area, :administrative_area_iso2, :attention, :post_box,
9
9
  :building_leading_type, :dependent_locality_name, :thoroughfare_type,
10
10
  :dependent_thoroughfare_postdirection, :double_dependent_locality, :premise_number,
11
11
  :dependent_thoroughfare_type, :post_box_type, :building, :sub_administrative_area, :postal_code_extra,
12
12
  :sub_building_name, :postal_code, :dependent_locality, :premise_type, :sub_building_number,
13
+ :short_address_code, :sub_building_leading_type, :sub_building_block, :sub_building_door, :sub_building_staircase,
13
14
  :super_administrative_area, :premise_extra, :dependent_thoroughfare_predirection,
14
15
  :building_trailing_type, :thoroughfare_predirection, :building_name, :level_type, :level_number,
15
16
  :country_iso_3, :sub_building_type, :additional_content, :delivery_installation, :delivery_installation_type,
@@ -20,8 +21,7 @@ module SmartyStreets
20
21
  @super_administrative_area = obj.fetch('super_administrative_area', nil)
21
22
  @administrative_area = obj.fetch('administrative_area', nil)
22
23
  @administrative_area_iso2 = obj.fetch('administrative_area_iso2', nil)
23
- @administrative_area_short = obj.fetch('administrative_area_short', nil)
24
- @administrative_area_long = obj.fetch('administrative_area_long', nil)
24
+ @attention = obj.fetch('attention', nil)
25
25
  @sub_administrative_area = obj.fetch('sub_administrative_area', nil)
26
26
  @dependent_locality= obj.fetch('dependent_locality', nil)
27
27
  @dependent_locality_name = obj.fetch('dependent_locality_name', nil)
@@ -35,6 +35,11 @@ module SmartyStreets
35
35
  @premise_number = obj.fetch('premise_number', nil)
36
36
  @premise_prefix_number = obj.fetch('premise_prefix_number', nil)
37
37
  @premise_type = obj.fetch('premise_type', nil)
38
+ @short_address_code = obj.fetch('short_address_code', nil)
39
+ @sub_building_leading_type = obj.fetch('sub_building_leading_type', nil)
40
+ @sub_building_block = obj.fetch('sub_building_block', nil)
41
+ @sub_building_door = obj.fetch('sub_building_door', nil)
42
+ @sub_building_staircase = obj.fetch('sub_building_staircase', nil)
38
43
  @thoroughfare = obj.fetch('thoroughfare', nil)
39
44
  @thoroughfare_predirection = obj.fetch('thoroughfare_predirection', nil)
40
45
  @thoroughfare_postdirection = obj.fetch('thoroughfare_postdirection', nil)
@@ -1,9 +1,26 @@
1
+ require_relative '../exceptions'
2
+
1
3
  module SmartyStreets
2
4
  module InternationalStreet
3
- module LanguageMode
4
- NATIVE = 'native'.freeze
5
+ # A closed set of valid Language values, the closest Ruby equivalent to an enum: a fixed set of frozen
6
+ # instances rather than an arbitrary string.
7
+ class LanguageMode < Data.define(:value)
8
+ NATIVE = new('native')
9
+ LATIN = new('latin')
10
+
11
+ ALL = [NATIVE, LATIN].freeze
12
+
13
+ # Resolves a LanguageMode instance or a raw value (eg. from user input or config) into a LanguageMode,
14
+ # matching 'native'/'latin' regardless of case.
15
+ def self.from_value(value)
16
+ return value if value.is_a?(LanguageMode)
17
+
18
+ match = ALL.find { |mode| mode.value.casecmp?(value.to_s) }
19
+ raise SmartyStreets::UnprocessableEntityError,
20
+ "invalid Language value; must be unset, 'native', or 'latin' (case-insensitive)" if match.nil?
5
21
 
6
- LATIN = 'latin'.freeze
22
+ match
23
+ end
7
24
  end
8
25
  end
9
26
  end
@@ -1,3 +1,5 @@
1
+ require_relative 'language_mode'
2
+
1
3
  module SmartyStreets
2
4
  module InternationalStreet
3
5
  # In addition to holding all of the input data for this lookup, this class also will contain the
@@ -71,18 +73,9 @@ module SmartyStreets
71
73
  end
72
74
 
73
75
  def ensure_enough_info
74
- raise UnprocessableEntityError, 'Country field is required.' if missing_country
75
-
76
- return true if has_freeform
77
-
78
- raise UnprocessableEntityError, 'Either freeform or address1 is required.' if missing_address1
79
-
80
- return true if has_postal_code
81
-
82
- if missing_locality_or_administrative_area
83
- raise UnprocessableEntityError, 'Insufficient information:'\
84
- 'One or more required fields were not set on the lookup.'
85
- end
76
+ raise UnprocessableEntityError, 'Country field is required.' if field_is_missing(@country)
77
+ raise UnprocessableEntityError, 'Either freeform or address1 is required.' if field_is_missing(@freeform) && field_is_missing(@address1)
78
+ LanguageMode.from_value(@language) unless @language.nil?
86
79
  end
87
80
  end
88
81
  end
@@ -2,7 +2,7 @@ module SmartyStreets
2
2
  module InternationalStreet
3
3
  class RootLevel
4
4
  attr_reader :input_id, :organization, :address1, :address2, :address3, :address4, :address5, :address6, :address7,
5
- :address8, :address9, :address10, :address11, :address12
5
+ :address8
6
6
 
7
7
  def initialize(obj)
8
8
  @input_id = obj.fetch('input_id', nil)
@@ -15,10 +15,6 @@ module SmartyStreets
15
15
  @address6 = obj.fetch('address6', nil)
16
16
  @address7 = obj.fetch('address7', nil)
17
17
  @address8 = obj.fetch('address8', nil)
18
- @address9 = obj.fetch('address9', nil)
19
- @address10 = obj.fetch('address10', nil)
20
- @address11 = obj.fetch('address11', nil)
21
- @address12 = obj.fetch('address12', nil)
22
18
  end
23
19
  end
24
20
  end
@@ -47,6 +47,7 @@ module SmartyStreets
47
47
  request.body = smarty_request.payload
48
48
  request['User-Agent'] = "smartystreets (sdk:ruby@#{SmartyStreets::VERSION})"
49
49
  request['Referer'] = smarty_request.referer unless smarty_request.referer.nil?
50
+ request.basic_auth(smarty_request.basic_auth[0], smarty_request.basic_auth[1]) unless smarty_request.basic_auth.nil?
50
51
  set_custom_headers(smarty_request.header, request)
51
52
  request
52
53
  end
@@ -80,10 +81,8 @@ module SmartyStreets
80
81
 
81
82
  def self.set_custom_headers(smarty_header, request)
82
83
  smarty_header.each do |key, values|
83
- if values.respond_to? :each
84
- values.each do |value|
85
- request.add_field(key, value)
86
- end
84
+ if values.respond_to?(:each)
85
+ values.each { |value| request.add_field(key, value) }
87
86
  else
88
87
  request.add_field(key, values)
89
88
  end
@@ -7,6 +7,7 @@ module SmartyStreets
7
7
  end
8
8
 
9
9
  def deserialize(payload)
10
+ return {} if payload.nil? || payload.empty?
10
11
  JSON.load(payload)
11
12
  end
12
13
  end
@@ -1,6 +1,6 @@
1
1
  module SmartyStreets
2
2
  class Request
3
- attr_accessor :parameters, :payload, :url_components, :url_prefix, :referer, :header, :content_type
3
+ attr_accessor :parameters, :payload, :url_components, :url_prefix, :referer, :header, :content_type, :basic_auth
4
4
 
5
5
  def initialize
6
6
  @parameters = {}
@@ -10,6 +10,7 @@ module SmartyStreets
10
10
  @referer = nil
11
11
  @header = {}
12
12
  @content_type = 'application/json'
13
+ @basic_auth = nil
13
14
  end
14
15
  end
15
16
  end
@@ -8,5 +8,18 @@ module SmartyStreets
8
8
  @header = header
9
9
  @error = error
10
10
  end
11
+
12
+ # Case-insensitive lookup that works on both plain Hash (used by mocks)
13
+ # and Net::HTTPResponse (returned by the live HTTP transport, which
14
+ # exposes each_header but not each_pair).
15
+ def find_header(name)
16
+ return nil if @header.nil?
17
+ target = name.to_s.downcase
18
+ iterator = @header.respond_to?(:each_header) ? :each_header : :each_pair
19
+ @header.send(iterator) do |k, v|
20
+ return v if k.to_s.downcase == target
21
+ end
22
+ nil
23
+ end
11
24
  end
12
25
  end
@@ -1,8 +1,12 @@
1
+ require 'net/http'
2
+ require 'timeout'
3
+
1
4
  module SmartyStreets
2
5
  class RetrySender
3
6
  MAX_BACKOFF_DURATION = 10
4
7
  STATUS_TOO_MANY_REQUESTS = 429
5
8
  STATUS_TO_RETRY = [408, 429, 500, 502, 503, 504]
9
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, Errno::ETIMEDOUT]
6
10
 
7
11
  def initialize(max_retries, inner, sleeper, logger)
8
12
  @max_retries = max_retries
@@ -16,29 +20,44 @@ module SmartyStreets
16
20
 
17
21
  (1..@max_retries).each do |i|
18
22
 
19
- break if STATUS_TO_RETRY.include?(response.status_code.to_i) == false
20
-
21
- if response.status_code.to_i == STATUS_TOO_MANY_REQUESTS
22
- seconds_to_backoff = 10
23
- if response.header.nil? == false
24
- if Integer(response.header["Retry-After"], exception: false)
25
- seconds_to_backoff = response.header["Retry-After"].to_i
23
+ if timeout_error?(response.error)
24
+ timeout_backoff(i, response.error)
25
+ response = @inner.send(request)
26
+ elsif !STATUS_TO_RETRY.include?(response.status_code.to_i)
27
+ break
28
+ else
29
+ if response.status_code.to_i == STATUS_TOO_MANY_REQUESTS
30
+ seconds_to_backoff = 10
31
+ unless response.header.nil?
32
+ if Integer(response.header["Retry-After"], exception: false)
33
+ seconds_to_backoff = response.header["Retry-After"].to_i
34
+ end
26
35
  end
36
+ rate_limit_backoff(seconds_to_backoff)
37
+ else
38
+ backoff(i)
27
39
  end
28
- rate_limit_backoff(seconds_to_backoff)
29
- else
30
- backoff(i)
40
+ response = @inner.send(request)
31
41
  end
32
- response = @inner.send(request)
33
42
  end
34
43
 
35
44
  response
36
45
  end
37
46
 
38
- def backoff(attempt)
47
+ def timeout_error?(error)
48
+ return false if error.nil?
49
+
50
+ TIMEOUT_ERRORS.any? { |klass| error.is_a?(klass) }
51
+ end
52
+
53
+ def timeout_backoff(attempt, error)
54
+ backoff(attempt, "Timeout error (#{error.class}).")
55
+ end
56
+
57
+ def backoff(attempt, message = 'There was an error processing the request.')
39
58
  backoff_duration = [attempt, MAX_BACKOFF_DURATION].min
40
59
 
41
- @logger.log("There was an error processing the request. Retrying in #{backoff_duration} seconds...")
60
+ @logger.log("#{message} Retrying in #{backoff_duration} seconds...")
42
61
  @sleeper.sleep(backoff_duration)
43
62
  end
44
63
 
@@ -1,3 +1,4 @@
1
+ require 'json'
1
2
  require_relative 'exceptions'
2
3
  require_relative 'errors'
3
4
 
@@ -18,43 +19,59 @@ module SmartyStreets
18
19
  response
19
20
  end
20
21
 
22
+ private
23
+
21
24
  def parse_rate_limit_response(response)
22
- error_message = ""
23
- if !response.payload.nil?
24
- response_json = JSON.parse(response.payload)
25
- response_json["errors"].each do |error|
26
- error_message += (" " + error["message"])
25
+ TooManyRequestsError.new(from_message(response, TOO_MANY_REQUESTS))
26
+ end
27
+
28
+ def from_message(response, fallback)
29
+ body = response.payload.nil? ? '' : response.payload.to_s.strip
30
+ unless body.empty?
31
+ begin
32
+ errors = JSON.parse(response.payload)["errors"]
33
+ rescue JSON::ParserError, TypeError
34
+ errors = nil
35
+ end
36
+ unless errors.nil? || errors.empty?
37
+ message = errors.map { |error| error["message"] }.join(" ")
38
+ return message unless message.empty?
27
39
  end
28
- error_message.strip!
29
- end
30
- if error_message == ""
31
- error_message = TOO_MANY_REQUESTS
32
40
  end
33
- TooManyRequestsError.new(error_message)
41
+
42
+ "#{fallback} Body: #{body}".strip
34
43
  end
35
44
 
36
45
  def assign_exception(response)
37
- response.error = case response.status_code
38
- when '304'
39
- NotModifiedInfo.new(NOT_MODIFIED)
46
+ response.error = case response.status_code.to_s
47
+ when '200', '304'
48
+ nil
40
49
  when '401'
41
- BadCredentialsError.new(BAD_CREDENTIALS)
50
+ BadCredentialsError.new(from_message(response, BAD_CREDENTIALS))
42
51
  when '402'
43
- PaymentRequiredError.new(PAYMENT_REQUIRED)
52
+ PaymentRequiredError.new(from_message(response, PAYMENT_REQUIRED))
53
+ when '403'
54
+ ForbiddenError.new(from_message(response, FORBIDDEN))
55
+ when '408'
56
+ RequestTimeoutError.new(from_message(response, REQUEST_TIMEOUT))
44
57
  when '413'
45
- RequestEntityTooLargeError.new(REQUEST_ENTITY_TOO_LARGE)
58
+ RequestEntityTooLargeError.new(from_message(response, REQUEST_ENTITY_TOO_LARGE))
46
59
  when '400'
47
- BadRequestError.new(BAD_REQUEST)
60
+ BadRequestError.new(from_message(response, BAD_REQUEST))
48
61
  when '422'
49
- UnprocessableEntityError.new(UNPROCESSABLE_ENTITY)
62
+ UnprocessableEntityError.new(from_message(response, UNPROCESSABLE_ENTITY))
50
63
  when '429'
51
- TooManyRequestsError.new(TOO_MANY_REQUESTS)
64
+ TooManyRequestsError.new(from_message(response, TOO_MANY_REQUESTS))
52
65
  when '500'
53
- InternalServerError.new(INTERNAL_SERVER_ERROR)
66
+ InternalServerError.new(from_message(response, INTERNAL_SERVER_ERROR))
67
+ when '502'
68
+ BadGatewayError.new(from_message(response, BAD_GATEWAY))
54
69
  when '503'
55
- ServiceUnavailableError.new(SERVICE_UNAVAILABLE)
70
+ ServiceUnavailableError.new(from_message(response, SERVICE_UNAVAILABLE))
71
+ when '504'
72
+ GatewayTimeoutError.new(from_message(response, GATEWAY_TIMEOUT))
56
73
  else
57
- nil
74
+ SmartyError.new(from_message(response, "The server returned an unexpected HTTP status code: #{response.status_code}"))
58
75
  end
59
76
  end
60
77
  end