friendly_shipping 0.2.6 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -7
  3. data/lib/friendly_shipping.rb +2 -0
  4. data/lib/friendly_shipping/api_failure.rb +21 -0
  5. data/lib/friendly_shipping/api_result.rb +17 -0
  6. data/lib/friendly_shipping/request.rb +3 -2
  7. data/lib/friendly_shipping/services/ship_engine.rb +28 -6
  8. data/lib/friendly_shipping/services/ship_engine/parse_carrier_response.rb +32 -28
  9. data/lib/friendly_shipping/services/ship_engine/parse_label_response.rb +15 -15
  10. data/lib/friendly_shipping/services/ship_engine/parse_rate_estimate_response.rb +11 -1
  11. data/lib/friendly_shipping/services/ship_engine/parse_void_response.rb +11 -9
  12. data/lib/friendly_shipping/services/ups.rb +47 -1
  13. data/lib/friendly_shipping/services/ups/parse_address_validation_response.rb +50 -0
  14. data/lib/friendly_shipping/services/ups/parse_city_state_lookup_response.rb +30 -0
  15. data/lib/friendly_shipping/services/ups/parse_rate_response.rb +31 -25
  16. data/lib/friendly_shipping/services/ups/serialize_address_validation_request.rb +39 -0
  17. data/lib/friendly_shipping/services/ups/serialize_city_state_lookup_request.rb +26 -0
  18. data/lib/friendly_shipping/services/usps.rb +43 -3
  19. data/lib/friendly_shipping/services/usps/parse_address_validation_response.rb +39 -0
  20. data/lib/friendly_shipping/services/usps/parse_city_state_lookup_response.rb +37 -0
  21. data/lib/friendly_shipping/services/usps/parse_rate_response.rb +12 -7
  22. data/lib/friendly_shipping/services/usps/parse_xml_response.rb +6 -6
  23. data/lib/friendly_shipping/services/usps/serialize_address_validation_request.rb +25 -0
  24. data/lib/friendly_shipping/services/usps/serialize_city_state_lookup_request.rb +20 -0
  25. data/lib/friendly_shipping/services/usps/serialize_rate_request.rb +4 -4
  26. data/lib/friendly_shipping/version.rb +1 -1
  27. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6848c5420a248cc3b6a382549bbe8833a73cf69a
4
- data.tar.gz: 7b935b92f55ef273aaf771114a1009e58920a024
3
+ metadata.gz: 821bec044ee86ff1e468dd392ae55c475510af6c
4
+ data.tar.gz: 153b7226c2e01afe5b6d7fdc260e39b712986ae5
5
5
  SHA512:
6
- metadata.gz: 778355fb23316ce44d5cdb84e158efce2c2f7f44281677a995df50a47853f2efe92154d8b956c747c24cbd074b70d06e096384afb50a61572537fb53a3dee480
7
- data.tar.gz: dc0dc9ef40db1f14d5a81a08189bf594e476af2b7c8d69eb2cc9ed0b7d93fc68cef1e7d8c60e4fbd64a559e48aa53afec98ba36247ff37adca9ebfb38cf8b35b
6
+ metadata.gz: a1ad8abee5b1a56f33cc8c5b8a4d55d4bce1f056e74a0719ea237ab577587921b43ada0ca50f35ab52bba261b2fc18598e612a82185a9d172c3d45918cf07f8e
7
+ data.tar.gz: 29a07b0165a570d6cbf5c008e2ff1a67aa6ce2e245be10357cbaaeb13b88c888e74419dec65ca9df7ce147e38524eb1b42a55393dba6f17c8cf2d2e4cef61338
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
- # FriendlyShipping
1
+ # FriendlyShipping - the friendly shipping provider API wrapper
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/friendly_shipping`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ This gem provides wrappers for popular shipping provider APIs. Currently, there are implementations for rate quoting and address validation, as well as shipping label generation via the `ShipStation` API.
6
4
 
7
5
  ## Installation
8
6
 
@@ -22,7 +20,74 @@ Or install it yourself as:
22
20
 
23
21
  ## Usage
24
22
 
25
- TODO: Write usage instructions here
23
+ The entry point for using FriendlyShipping are the `service` objects that each represent an API provider. Currently, there are three services:
24
+
25
+ 1. `FriendlyShipping::Services::Ups`
26
+ 2. `FriendlyShipping::Services::Usps`
27
+ 3. `FriendlyShipping::Services::ShipEngine`
28
+
29
+ The services are instantiated with the credentials they need as well as a `test` flag to indicate whether to use their respective `sandbox` environments.
30
+
31
+ The individual API endpoints can be reached through the public methods of the respective service objects, such as `#rate_estimates`, `#labels`, or `#address_validation`. This gem makes heavy use of classes from the [`physical` gem](https://github.com/friendlycart/physical) to represent the physical world (shipments, packages, addresses, and items).
32
+
33
+ ### Return values
34
+
35
+ All calls to `FriendlyShipping` will return a `Dry::Monads::Result` instance, so either a `Success` or `Failure`. Wrapped inside this monad is a `FriendlyShipping::ApiResult` (in the success case) or a `FriendlyShipping::ApiFailure` object that contains the desired data under `#data` `ApiResult` and `ApiFailure` also contain the original request generated by this gem and the response returned by the respective service.
36
+
37
+ ### Supported Services
38
+
39
+ #### ShipEngine
40
+
41
+ The service class for ShipEngine is `FriendlyShipping::Services::ShipEngine`. Initialize like so:
42
+
43
+ ```rb
44
+ service = FriendlyShipping::Services::ShipEngine.new(token: ENV['SHIPENGINE_TOKEN'], test: true)
45
+ ```
46
+
47
+ The following methods are supported:
48
+
49
+ - `#carriers` - List all configured carriers
50
+ - `#rate_estimates(physical_shipment, carriers: [friendly_shipping_carrier])` - Get rate estimates for a shipment
51
+ - `#labels(physical_shipment)` - Get labels for a shipments. Currently only supports USPS labels, other services are untested. The API of this method is still subject to change.
52
+ - `#void(physical_label)` - Void a label and get the cost refunded
53
+
54
+ #### UPS (United Parcel Service)
55
+
56
+ The service class for UPS is `FriendlyShipping::Services::Ups`. Initialize like so:
57
+
58
+ ```rb
59
+ service = FriendlyShipping::Services::Ups.new(
60
+ key: ENV['UPS_API_KEY'],
61
+ login: ENV['UPS_API_LOGIN'],
62
+ password: ENV['UPS_API_PASSWORD'],
63
+ test: true
64
+ )
65
+ ```
66
+
67
+ The following methods are supported:
68
+
69
+ - `#carriers` - List all configured carriers (always returns UPS)
70
+ - `#rate_estimates(physical_shipment)` - Get rate estimates for a shipment
71
+ - `#address_validation(physical_location)` - Perform a detailed address validation and determine whether an address is commercial or residential.
72
+ - `#city_state_lookup(physical_location)` - Lookup City and State for a given ZIP code.
73
+
74
+ #### USPS (United States Postal Service)
75
+
76
+ The service class for USPS is `FriendlyShipping::Services::Usps`. Initialize like so:
77
+
78
+ ```rb
79
+ service = FriendlyShipping::Services::Usps.new(
80
+ login: ENV['USPS_API_LOGIN'],
81
+ test: true
82
+ )
83
+ ```
84
+
85
+ The following methods are supported:
86
+
87
+ - `#carriers` - List all configured carriers (always returns USPS)
88
+ - `#rate_estimates(physical_shipment)` - Get rate estimates for a shipment
89
+ - `#address_validation(physical_location)` - Perform a detailed address validation and determine whether an address is commercial or residential.
90
+ - `#city_state_lookup(physical_location)` - Lookup City and State for a given ZIP code.
26
91
 
27
92
  ## Development
28
93
 
@@ -32,7 +97,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
32
97
 
33
98
  ## Contributing
34
99
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/friendly_shipping. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
100
+ Bug reports and pull requests are welcome on GitHub at https://github.com/friendlycart/friendly_shipping. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
101
 
37
102
  ## License
38
103
 
@@ -40,4 +105,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
40
105
 
41
106
  ## Code of Conduct
42
107
 
43
- Everyone interacting in the FriendlyShipping project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/friendly_shipping/blob/master/CODE_OF_CONDUCT.md).
108
+ Everyone interacting in the FriendlyShipping project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/friendlycart/friendly_shipping/blob/master/CODE_OF_CONDUCT.md).
@@ -9,6 +9,8 @@ require "friendly_shipping/carrier"
9
9
  require "friendly_shipping/shipping_method"
10
10
  require "friendly_shipping/label"
11
11
  require "friendly_shipping/rate"
12
+ require "friendly_shipping/api_result"
13
+ require "friendly_shipping/api_failure"
12
14
 
13
15
  require "friendly_shipping/services/ship_engine"
14
16
  require "friendly_shipping/services/ups"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ class ApiFailure
5
+ attr_reader :failure, :original_request, :original_response
6
+
7
+ def initialize(failure, original_request:, original_response:)
8
+ @failure = failure
9
+
10
+ # We do not want to attach debugging information in every single response to save memory in production
11
+ return unless original_request&.debug
12
+
13
+ @original_request = original_request
14
+ @original_response = original_response
15
+ end
16
+
17
+ def to_s
18
+ failure.to_s
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ class ApiResult
5
+ attr_reader :data, :original_request, :original_response
6
+
7
+ def initialize(data, original_request: nil, original_response: nil)
8
+ @data = data
9
+
10
+ # We do not want to attach debugging information in every single response to save memory in production
11
+ return unless original_request&.debug
12
+
13
+ @original_request = original_request
14
+ @original_response = original_response
15
+ end
16
+ end
17
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module FriendlyShipping
4
4
  class Request
5
- attr_reader :url, :body, :headers
5
+ attr_reader :url, :body, :headers, :debug
6
6
 
7
- def initialize(url:, body: nil, headers: {})
7
+ def initialize(url:, body: nil, headers: {}, debug: false)
8
8
  @url = url
9
9
  @body = body
10
10
  @headers = headers
11
+ @debug = debug
11
12
  end
12
13
  end
13
14
  end
@@ -24,27 +24,49 @@ module FriendlyShipping
24
24
  @client = client
25
25
  end
26
26
 
27
+ # Get configured carriers from USPS
28
+ #
29
+ # @return [Result<ApiResult<Array<Carrier>>>] Carriers configured in your shipstation account
27
30
  def carriers
28
31
  request = FriendlyShipping::Request.new(
29
32
  url: API_BASE + API_PATHS[:carriers],
30
33
  headers: request_headers
31
34
  )
32
35
  client.get(request).fmap do |response|
33
- ParseCarrierResponse.new(response: response).call
36
+ ParseCarrierResponse.call(request: request, response: response)
34
37
  end
35
38
  end
36
39
 
37
- def rate_estimates(shipment, carriers)
40
+ # Get rate estimates from ShipEngine
41
+ #
42
+ # @param [Physical::Shipment] shipment The shipment object we're trying to get results for
43
+ #
44
+ # @options[:carriers] [Physical::Carrier] The carriers we want to get rates from. What counts
45
+ # here is the carrier code, so by specifying them upfront you can save a request.
46
+ #
47
+ # @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
48
+ # When the parsing is not successful or ShipEngine can't give us rates, a Failure monad containing something that
49
+ # can be serialized into an error message using `to_s`.
50
+ def rate_estimates(shipment, options = {})
51
+ selected_carriers = options[:carriers] || carriers.value!.data
38
52
  request = FriendlyShipping::Request.new(
39
53
  url: API_BASE + 'rates/estimate',
40
- body: SerializeRateEstimateRequest.call(shipment: shipment, carriers: carriers).to_json,
54
+ body: SerializeRateEstimateRequest.call(shipment: shipment, carriers: selected_carriers).to_json,
41
55
  headers: request_headers
42
56
  )
43
- client.post(request).fmap do |response|
44
- ParseRateEstimateResponse.call(response: response, request: request, carriers: carriers)
57
+ client.post(request).bind do |response|
58
+ ParseRateEstimateResponse.call(response: response, request: request, carriers: selected_carriers)
45
59
  end
46
60
  end
47
61
 
62
+ # Get label(s) from ShipEngine
63
+ #
64
+ # @param [Physical::Shipment] shipment The shipment object we're trying to get labels for
65
+ # Note: Some ShipEngine carriers, notably USPS, only support one package per shipment, and that's
66
+ # all that the integration supports at this point.
67
+ #
68
+ # @return [Result<ApiResult<Array<FriendlyShipping::Label>>>] The label returned.
69
+ #
48
70
  def labels(shipment)
49
71
  request = FriendlyShipping::Request.new(
50
72
  url: API_BASE + API_PATHS[:labels],
@@ -63,7 +85,7 @@ module FriendlyShipping
63
85
  headers: request_headers
64
86
  )
65
87
  client.put(request).bind do |response|
66
- ParseVoidResponse.new(response: response).call
88
+ ParseVoidResponse.call(request: request, response: response)
67
89
  end
68
90
  end
69
91
 
@@ -6,41 +6,45 @@ module FriendlyShipping
6
6
  module Services
7
7
  class ShipEngine
8
8
  class ParseCarrierResponse
9
- def initialize(response:)
10
- @response = response
11
- end
9
+ class << self
10
+ def call(request:, response:)
11
+ parsed_json = JSON.parse(response.body)
12
+ carriers = parsed_json['carriers'].map do |carrier_data|
13
+ carrier = FriendlyShipping::Carrier.new(
14
+ id: carrier_data['carrier_id'],
15
+ name: carrier_data['friendly_name'],
16
+ code: carrier_data['carrier_code'],
17
+ balance: carrier_data['balance'],
18
+ data: carrier_data
19
+ )
12
20
 
13
- def call
14
- parsed_json = JSON.parse(@response.body)
15
- parsed_json['carriers'].map do |carrier_data|
16
- carrier = FriendlyShipping::Carrier.new(
17
- id: carrier_data['carrier_id'],
18
- name: carrier_data['friendly_name'],
19
- code: carrier_data['carrier_code'],
20
- balance: carrier_data['balance'],
21
- data: carrier_data
22
- )
21
+ carrier_data['services'].each do |method_hash|
22
+ shipping_method = parse_shipping_method(carrier, method_hash)
23
+ carrier.shipping_methods << shipping_method
24
+ end
23
25
 
24
- carrier_data['services'].each do |method_hash|
25
- shipping_method = parse_shipping_method(carrier, method_hash)
26
- carrier.shipping_methods << shipping_method
26
+ carrier
27
27
  end
28
28
 
29
- carrier
29
+ ApiResult.new(
30
+ carriers,
31
+ original_request: request,
32
+ original_response: response
33
+ )
30
34
  end
31
- end
32
35
 
33
- private
36
+ private
34
37
 
35
- def parse_shipping_method(carrier, shipping_method_data)
36
- FriendlyShipping::ShippingMethod.new(
37
- carrier: carrier,
38
- name: shipping_method_data["name"],
39
- service_code: shipping_method_data["service_code"],
40
- domestic: shipping_method_data["domestic"],
41
- international: shipping_method_data["international"],
42
- multi_package: shipping_method_data["is_multi_package_supported"]
43
- )
38
+ def parse_shipping_method(carrier, shipping_method_data)
39
+ FriendlyShipping::ShippingMethod.new(
40
+ carrier: carrier,
41
+ name: shipping_method_data["name"],
42
+ service_code: shipping_method_data["service_code"],
43
+ domestic: shipping_method_data["domestic"],
44
+ international: shipping_method_data["international"],
45
+ multi_package: shipping_method_data["is_multi_package_supported"]
46
+ )
47
+ end
44
48
  end
45
49
  end
46
50
  end
@@ -9,6 +9,7 @@ module FriendlyShipping
9
9
  class ParseLabelResponse
10
10
  def self.call(request:, response:)
11
11
  parsed_json = JSON.parse(response.body)
12
+
12
13
  label_uri_string = parsed_json['label_download']['href']
13
14
  label_data = nil
14
15
  label_url = nil
@@ -17,21 +18,20 @@ module FriendlyShipping
17
18
  else
18
19
  label_url = label_uri_string
19
20
  end
20
- [
21
- FriendlyShipping::Label.new(
22
- id: parsed_json['label_id'],
23
- shipment_id: parsed_json['shipment_id'],
24
- tracking_number: parsed_json['tracking_number'],
25
- service_code: parsed_json['service_code'],
26
- label_href: label_url,
27
- label_data: label_data,
28
- label_format: parsed_json['label_format'].to_sym,
29
- shipment_cost: parsed_json['shipment_cost']['amount'],
30
- data: parsed_json,
31
- original_request: request,
32
- original_response: response
33
- )
34
- ]
21
+
22
+ label = FriendlyShipping::Label.new(
23
+ id: parsed_json['label_id'],
24
+ shipment_id: parsed_json['shipment_id'],
25
+ tracking_number: parsed_json['tracking_number'],
26
+ service_code: parsed_json['service_code'],
27
+ label_href: label_url,
28
+ label_data: label_data,
29
+ label_format: parsed_json['label_format'].to_sym,
30
+ shipment_cost: parsed_json['shipment_cost']['amount'],
31
+ data: parsed_json
32
+ )
33
+
34
+ ApiResult.new([label], original_request: request, original_response: response)
35
35
  end
36
36
  end
37
37
  end
@@ -7,10 +7,12 @@ module FriendlyShipping
7
7
  module Services
8
8
  class ShipEngine
9
9
  class ParseRateEstimateResponse
10
+ extend Dry::Monads::Result::Mixin
11
+
10
12
  class << self
11
13
  def call(response:, carriers:, request:)
12
14
  parsed_json = JSON.parse(response.body)
13
- parsed_json.map do |rate|
15
+ rates = parsed_json.map do |rate|
14
16
  carrier = carriers.detect { |c| c.id == rate['carrier_id'] }
15
17
  next unless carrier
16
18
 
@@ -29,6 +31,14 @@ module FriendlyShipping
29
31
  original_response: response
30
32
  )
31
33
  end.compact
34
+
35
+ Success(
36
+ ApiResult.new(
37
+ rates,
38
+ original_request: request,
39
+ original_response: response
40
+ )
41
+ )
32
42
  end
33
43
 
34
44
  private
@@ -6,18 +6,20 @@ module FriendlyShipping
6
6
  module Services
7
7
  class ShipEngine
8
8
  class ParseVoidResponse
9
- include Dry::Monads::Result::Mixin
9
+ extend Dry::Monads::Result::Mixin
10
10
 
11
- attr_reader :response
12
-
13
- def initialize(response:)
14
- @response = response
15
- end
16
-
17
- def call
11
+ def self.call(request:, response:)
18
12
  parsed_json = JSON.parse(response.body)
19
13
  approved, message = parsed_json["approved"], parsed_json["message"]
20
- approved ? Success(message) : Failure(message)
14
+ if approved
15
+ Success(
16
+ ApiResult.new(message, original_request: request, original_response: response)
17
+ )
18
+ else
19
+ Failure(
20
+ ApiFailure.new(message, original_request: request, original_response: response)
21
+ )
22
+ end
21
23
  end
22
24
  end
23
25
  end
@@ -3,7 +3,11 @@
3
3
  require 'dry/monads/result'
4
4
  require 'friendly_shipping/services/ups/client'
5
5
  require 'friendly_shipping/services/ups/serialize_access_request'
6
+ require 'friendly_shipping/services/ups/serialize_city_state_lookup_request'
7
+ require 'friendly_shipping/services/ups/serialize_address_validation_request'
6
8
  require 'friendly_shipping/services/ups/serialize_rating_service_selection_request'
9
+ require 'friendly_shipping/services/ups/parse_address_validation_response'
10
+ require 'friendly_shipping/services/ups/parse_city_state_lookup_response'
7
11
  require 'friendly_shipping/services/ups/parse_rate_response'
8
12
  require 'friendly_shipping/services/ups/shipping_methods'
9
13
 
@@ -25,6 +29,8 @@ module FriendlyShipping
25
29
  LIVE_URL = 'https://onlinetools.ups.com'
26
30
 
27
31
  RESOURCES = {
32
+ address_validation: '/ups.app/xml/XAV',
33
+ city_state_lookup: '/ups.app/xml/AV',
28
34
  rates: '/ups.app/xml/Rate'
29
35
  }.freeze
30
36
 
@@ -40,7 +46,11 @@ module FriendlyShipping
40
46
  Success([CARRIER])
41
47
  end
42
48
 
43
- def rate_estimates(shipment, _carriers)
49
+ # Get rates for a shipment
50
+ # @param [Physical::Shipment] location The shipment we want to get rates for
51
+ # @return [Result<ApiResult<Array<Rate>>>] The rates returned from UPS encoded in a
52
+ # `FriendlyShipping::ApiResult` object.
53
+ def rate_estimates(shipment, _options = {})
44
54
  rate_request_xml = SerializeRatingServiceSelectionRequest.call(shipment: shipment)
45
55
  url = base_url + RESOURCES[:rates]
46
56
  request = FriendlyShipping::Request.new(
@@ -53,6 +63,42 @@ module FriendlyShipping
53
63
  end
54
64
  end
55
65
 
66
+ # Validate an address.
67
+ # @param [Physical::Location] location The address we want to verify
68
+ # @return [Result<ApiResult<Array<Physical::Location>>>] The response data from UPS encoded in a
69
+ # `Physical::Location` object. Name and Company name are always nil, the
70
+ # address lines will be made conformant to what UPS considers right. The returned location will
71
+ # have the address_type set if possible.
72
+ def address_validation(location)
73
+ address_validation_request_xml = SerializeAddressValidationRequest.call(location: location)
74
+ url = base_url + RESOURCES[:address_validation]
75
+ request = FriendlyShipping::Request.new(
76
+ url: url,
77
+ body: access_request_xml + address_validation_request_xml
78
+ )
79
+
80
+ client.post(request).bind do |response|
81
+ ParseAddressValidationResponse.call(response: response, request: request)
82
+ end
83
+ end
84
+
85
+ # Find city and state for a given ZIP code
86
+ # @param [Physical::Location] location A location object with country and ZIP code set
87
+ # @return [Result<ApiResult<Array<Physical::Location>>>] The response data from UPS encoded in a
88
+ # `Physical::Location` object. Country, City and ZIP code will be set, everything else nil.
89
+ def city_state_lookup(location)
90
+ city_state_lookup_request_xml = SerializeCityStateLookupRequest.call(location: location)
91
+ url = base_url + RESOURCES[:city_state_lookup]
92
+ request = FriendlyShipping::Request.new(
93
+ url: url,
94
+ body: access_request_xml + city_state_lookup_request_xml
95
+ )
96
+
97
+ client.post(request).bind do |response|
98
+ ParseCityStateLookupResponse.call(response: response, request: request, location: location)
99
+ end
100
+ end
101
+
56
102
  private
57
103
 
58
104
  def access_request_xml
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class ParseAddressValidationResponse
7
+ extend Dry::Monads::Result::Mixin
8
+
9
+ def self.call(request:, response:)
10
+ parsing_result = ParseXMLResponse.call(response.body, 'AddressValidationResponse')
11
+
12
+ parsing_result.bind do |xml|
13
+ if xml.at('NoCandidatesIndicator')
14
+ Failure(
15
+ FriendlyShipping::ApiFailure.new(
16
+ 'Address is probably invalid. No similar valid addresses found.',
17
+ original_request: request,
18
+ original_response: response
19
+ )
20
+ )
21
+ else
22
+ Success(
23
+ FriendlyShipping::ApiResult.new(
24
+ build_suggestions(xml),
25
+ original_request: request,
26
+ original_response: response
27
+ )
28
+ )
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.build_suggestions(xml)
34
+ xml.xpath('//AddressKeyFormat').map do |address_fragment|
35
+ Physical::Location.new(
36
+ address1: address_fragment.xpath('AddressLine[1]')[0]&.text,
37
+ address2: address_fragment.xpath('AddressLine[2]')[0]&.text,
38
+ company_name: address_fragment.at('ConsigneeName')&.text,
39
+ city: address_fragment.at('PoliticalDivision2')&.text,
40
+ region: address_fragment.at('PoliticalDivision1')&.text,
41
+ country: address_fragment.at('CountryCode')&.text,
42
+ zip: "#{address_fragment.at('PostcodePrimaryLow')&.text}-#{address_fragment.at('PostcodeExtendedLow')&.text}",
43
+ address_type: address_fragment.at('AddressClassification/Description')&.text&.downcase
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class ParseCityStateLookupResponse
7
+ extend Dry::Monads::Result::Mixin
8
+
9
+ def self.call(request:, response:, location:)
10
+ parsing_result = ParseXMLResponse.call(response.body, 'AddressValidationResponse')
11
+
12
+ parsing_result.fmap do |xml|
13
+ FriendlyShipping::ApiResult.new(
14
+ [
15
+ Physical::Location.new(
16
+ city: xml.at('AddressValidationResult/Address/City')&.text,
17
+ region: xml.at('AddressValidationResult/Address/StateProvinceCode')&.text,
18
+ country: location.country,
19
+ zip: xml.at('AddressValidationResult/Address/PostcodePrimaryLow')&.text,
20
+ )
21
+ ],
22
+ original_request: request,
23
+ original_response: response
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -9,33 +9,39 @@ module FriendlyShipping
9
9
  def self.call(request:, response:, shipment:)
10
10
  parsing_result = ParseXMLResponse.call(response.body, 'RatingServiceSelectionResponse')
11
11
  parsing_result.fmap do |xml|
12
- xml.root.css('> RatedShipment').map do |rated_shipment|
13
- service_code = rated_shipment.at('Service/Code').text
14
- shipping_method = CARRIER.shipping_methods.detect do |sm|
15
- sm.service_code == service_code && shipment.origin.country.in?(sm.origin_countries)
16
- end
17
- days_to_delivery = rated_shipment.at('GuaranteedDaysToDelivery').text.to_i
18
- currency = Money::Currency.new(rated_shipment.at('TotalCharges/CurrencyCode').text)
19
- total_cents = rated_shipment.at('TotalCharges/MonetaryValue').text.to_d * currency.subunit_to_unit
20
- insurance_price = rated_shipment.at('ServiceOptionsCharges/MonetaryValue').text.to_f
21
- negotiated_rate = rated_shipment.at(
22
- 'NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue'
23
- )&.text.to_f
12
+ FriendlyShipping::ApiResult.new(
13
+ build_rates(xml, shipment),
14
+ original_request: request,
15
+ original_response: response
16
+ )
17
+ end
18
+ end
24
19
 
25
- FriendlyShipping::Rate.new(
26
- shipping_method: shipping_method,
27
- amounts: { total: Money.new(total_cents, currency) },
28
- warnings: [rated_shipment.at("RatedShipmentWarning")&.text].compact,
29
- errors: [],
30
- data: {
31
- insurance_price: insurance_price,
32
- negotiated_rate: negotiated_rate,
33
- days_to_delivery: days_to_delivery
34
- },
35
- original_request: request,
36
- original_response: response
37
- )
20
+ def self.build_rates(xml, shipment)
21
+ xml.root.css('> RatedShipment').map do |rated_shipment|
22
+ service_code = rated_shipment.at('Service/Code').text
23
+ shipping_method = CARRIER.shipping_methods.detect do |sm|
24
+ sm.service_code == service_code && shipment.origin.country.in?(sm.origin_countries)
38
25
  end
26
+ days_to_delivery = rated_shipment.at('GuaranteedDaysToDelivery').text.to_i
27
+ currency = Money::Currency.new(rated_shipment.at('TotalCharges/CurrencyCode').text)
28
+ total_cents = rated_shipment.at('TotalCharges/MonetaryValue').text.to_d * currency.subunit_to_unit
29
+ insurance_price = rated_shipment.at('ServiceOptionsCharges/MonetaryValue').text.to_f
30
+ negotiated_rate = rated_shipment.at(
31
+ 'NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue'
32
+ )&.text.to_f
33
+
34
+ FriendlyShipping::Rate.new(
35
+ shipping_method: shipping_method,
36
+ amounts: { total: Money.new(total_cents, currency) },
37
+ warnings: [rated_shipment.at("RatedShipmentWarning")&.text].compact,
38
+ errors: [],
39
+ data: {
40
+ insurance_price: insurance_price,
41
+ negotiated_rate: negotiated_rate,
42
+ days_to_delivery: days_to_delivery
43
+ }
44
+ )
39
45
  end
40
46
  end
41
47
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class SerializeAddressValidationRequest
7
+ attr_reader :location
8
+
9
+ REQUEST_ACTION = 'XAV'
10
+ REQUEST_OPTIONS = {
11
+ validation: 1,
12
+ classification: 2,
13
+ both: 3
14
+ }.freeze
15
+
16
+ def self.call(location:)
17
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
18
+ xml.AddressValidationRequest do
19
+ xml.Request do
20
+ xml.RequestAction REQUEST_ACTION
21
+ xml.RequestOption REQUEST_OPTIONS[:both]
22
+ end
23
+
24
+ xml.AddressKeyFormat do
25
+ xml.AddressLine location.address1
26
+ xml.AddressLine location.address2
27
+ xml.PoliticalDivision2 location.city
28
+ xml.PoliticalDivision1 location.region.code
29
+ xml.PostcodePrimaryLow location.zip
30
+ xml.CountryCode location.country.code
31
+ end
32
+ end
33
+ end
34
+ xml_builder.to_xml
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class SerializeCityStateLookupRequest
7
+ REQUEST_ACTION = 'AV'
8
+
9
+ def self.call(location:)
10
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
11
+ xml.AddressValidationRequest do
12
+ xml.Request do
13
+ xml.RequestAction REQUEST_ACTION
14
+ end
15
+ xml.Address do
16
+ xml.PostalCode location.zip
17
+ xml.CountryCode location.country.code
18
+ end
19
+ end
20
+ end
21
+ xml_builder.to_xml
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -2,7 +2,11 @@
2
2
 
3
3
  require 'friendly_shipping/services/usps/client'
4
4
  require 'friendly_shipping/services/usps/shipping_methods'
5
+ require 'friendly_shipping/services/usps/serialize_address_validation_request'
6
+ require 'friendly_shipping/services/usps/serialize_city_state_lookup_request'
5
7
  require 'friendly_shipping/services/usps/serialize_rate_request'
8
+ require 'friendly_shipping/services/usps/parse_address_validation_response'
9
+ require 'friendly_shipping/services/usps/parse_city_state_lookup_response'
6
10
  require 'friendly_shipping/services/usps/parse_rate_response'
7
11
 
8
12
  module FriendlyShipping
@@ -23,7 +27,9 @@ module FriendlyShipping
23
27
  LIVE_URL = 'https://secure.shippingapis.com/ShippingAPI.dll'
24
28
 
25
29
  RESOURCES = {
26
- rates: 'RateV4',
30
+ address_validation: 'Verify',
31
+ city_state_lookup: 'CityStateLookup',
32
+ rates: 'RateV4'
27
33
  }.freeze
28
34
 
29
35
  def initialize(login:, test: true, client: Client)
@@ -51,8 +57,8 @@ module FriendlyShipping
51
57
  # @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
52
58
  # When the parsing is not successful or USPS can't give us rates, a Failure monad containing something that
53
59
  # can be serialized into an error message using `to_s`.
54
- def rate_estimates(shipment, _carriers, shipping_method: nil)
55
- rate_request_xml = SerializeRateRequest.call(shipment: shipment, login: login, shipping_method: shipping_method)
60
+ def rate_estimates(shipment, options = {})
61
+ rate_request_xml = SerializeRateRequest.call(shipment: shipment, login: login, shipping_method: options[:shipping_method])
56
62
  request = FriendlyShipping::Request.new(url: base_url, body: "API=#{RESOURCES[:rates]}&XML=#{CGI.escape rate_request_xml}")
57
63
 
58
64
  client.post(request).bind do |response|
@@ -60,6 +66,40 @@ module FriendlyShipping
60
66
  end
61
67
  end
62
68
 
69
+ # Validate an address.
70
+ # @param [Physical::Location] location The address we want to verify
71
+ # @return [Result<ApiResult<Array<Physical::Location>>>] The response data from UPS encoded in a
72
+ # `Physical::Location` object. Name and Company name are always nil, the
73
+ # address lines will be made conformant to what USPS considers right. The returned location will
74
+ # have the address_type set if possible.
75
+ def address_validation(location)
76
+ address_validation_request_xml = SerializeAddressValidationRequest.call(location: location, login: login)
77
+ request = FriendlyShipping::Request.new(
78
+ url: base_url,
79
+ body: "API=#{RESOURCES[:address_validation]}&XML=#{CGI.escape address_validation_request_xml}"
80
+ )
81
+
82
+ client.post(request).bind do |response|
83
+ ParseAddressValidationResponse.call(response: response, request: request)
84
+ end
85
+ end
86
+
87
+ # Find city and state for a given ZIP code
88
+ # @param [Physical::Location] location A location object with country and ZIP code set
89
+ # @return [Result<ApiResult<Array<Physical::Location>>>] The response data from USPS encoded in a
90
+ # `Physical::Location` object. Country, City and ZIP code will be set, everything else nil.
91
+ def city_state_lookup(location)
92
+ city_state_lookup_request_xml = SerializeCityStateLookupRequest.call(location: location, login: login)
93
+ request = FriendlyShipping::Request.new(
94
+ url: base_url,
95
+ body: "API=#{RESOURCES[:city_state_lookup]}&XML=#{CGI.escape city_state_lookup_request_xml}"
96
+ )
97
+
98
+ client.post(request).bind do |response|
99
+ ParseCityStateLookupResponse.call(response: response, request: request)
100
+ end
101
+ end
102
+
63
103
  private
64
104
 
65
105
  def base_url
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Usps
6
+ class ParseAddressValidationResponse
7
+ class << self
8
+ # Parse a response from USPS' address validation API
9
+ #
10
+ # @param [FriendlyShipping::Request] request The request that was used to obtain this Response
11
+ # @param [FriendlyShipping::Response] response The response that USPS returned
12
+ # @return [Result<FriendlyShipping::AddressValidationResult>]
13
+ def call(request:, response:)
14
+ # Filter out error responses and directly return a failure
15
+ parsing_result = ParseXMLResponse.call(response.body, 'AddressValidateResponse')
16
+ parsing_result.fmap do |xml|
17
+ address = xml.root.at('Address')
18
+ suggestions = [
19
+ Physical::Location.new(
20
+ address1: address&.at('Address2')&.text, # USPS swaps Address1 and Address2 in the response
21
+ address2: address&.at('Address1')&.text,
22
+ city: address&.at('City')&.text,
23
+ region: address&.at('State')&.text,
24
+ zip: address&.at('Zip5')&.text,
25
+ country: 'US'
26
+ )
27
+ ]
28
+ FriendlyShipping::ApiResult.new(
29
+ suggestions,
30
+ original_request: request,
31
+ original_response: response
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Usps
6
+ class ParseCityStateLookupResponse
7
+ class << self
8
+ # Parse a response from USPS' city/state lookup API
9
+ #
10
+ # @param [FriendlyShipping::Request] request The request that was used to obtain this Response
11
+ # @param [FriendlyShipping::Response] response The response that USPS returned
12
+ # @return [Result<FriendlyShipping::AddressValidationResult>]
13
+ def call(request:, response:)
14
+ # Filter out error responses and directly return a failure
15
+ parsing_result = ParseXMLResponse.call(response.body, 'CityStateLookupResponse')
16
+ parsing_result.fmap do |xml|
17
+ address = xml.root.at('ZipCode')
18
+ suggestions = [
19
+ Physical::Location.new(
20
+ city: address&.at('City')&.text,
21
+ region: address&.at('State')&.text,
22
+ zip: address&.at('Zip5')&.text,
23
+ country: 'US'
24
+ )
25
+ ]
26
+ FriendlyShipping::ApiResult.new(
27
+ suggestions,
28
+ original_request: request,
29
+ original_response: response,
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -16,19 +16,20 @@ module FriendlyShipping
16
16
  # @param [FriendlyShipping::Request] request The request that was used to obtain this Response
17
17
  # @param [FriendlyShipping::Response] response The response that USPS returned
18
18
  # @param [Physical::Shipment] shipment The shipment object we're trying to get results for
19
- # @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
19
+ # @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
20
20
  def call(request:, response:, shipment:)
21
21
  # Filter out error responses and directly return a failure
22
22
  parsing_result = ParseXMLResponse.call(response.body, 'RateV4Response')
23
+ rates = []
23
24
  parsing_result.fmap do |xml|
24
25
  # Get all the possible rates for each package
25
26
  rates_by_package = rates_from_response_node(xml, shipment)
26
27
 
27
- SHIPPING_METHODS.map do |shipping_method|
28
+ rates = SHIPPING_METHODS.map do |shipping_method|
28
29
  # For every package ...
29
- matching_rates = rates_by_package.map do |package, rates|
30
+ matching_rates = rates_by_package.map do |package, package_rates|
30
31
  # ... choose the rate that fits this package best.
31
- ChoosePackageRate.call(shipping_method, package, rates)
32
+ ChoosePackageRate.call(shipping_method, package, package_rates)
32
33
  end.compact # Some shipping rates are not available for every shipping method.
33
34
 
34
35
  # in this case, go to the next shipping method.
@@ -38,11 +39,15 @@ module FriendlyShipping
38
39
  FriendlyShipping::Rate.new(
39
40
  amounts: matching_rates.map(&:amounts).reduce({}, :merge),
40
41
  shipping_method: shipping_method,
41
- data: matching_rates.first.data,
42
- original_request: request,
43
- original_response: response
42
+ data: matching_rates.first.data
44
43
  )
45
44
  end.compact
45
+
46
+ ApiResult.new(
47
+ rates,
48
+ original_request: request,
49
+ original_response: response
50
+ )
46
51
  end
47
52
  end
48
53
 
@@ -5,13 +5,13 @@ module FriendlyShipping
5
5
  class Usps
6
6
  class ParseXMLResponse
7
7
  extend Dry::Monads::Result::Mixin
8
- ERROR_ROOT_TAG = 'Error'
8
+ ERROR_TAG = 'Error'
9
9
 
10
10
  class << self
11
11
  def call(response_body, expected_root_tag)
12
12
  xml = Nokogiri.XML(response_body)
13
13
 
14
- if xml.root.nil? || ![expected_root_tag, ERROR_ROOT_TAG].include?(xml.root.name)
14
+ if xml.root.nil? || ![expected_root_tag, 'Error'].include?(xml.root.name)
15
15
  Failure('Invalid document')
16
16
  elsif request_successful?(xml)
17
17
  Success(xml)
@@ -25,13 +25,13 @@ module FriendlyShipping
25
25
  private
26
26
 
27
27
  def request_successful?(xml)
28
- xml.xpath('Error/Number')&.text.blank?
28
+ xml.xpath('//Error/Number')&.text.blank?
29
29
  end
30
30
 
31
31
  def error_message(xml)
32
- number = xml.xpath('Error/Number')&.text
33
- desc = xml.xpath('Error/Description')&.text
34
- [number, desc].select(&:present?).join(': ').presence || 'USPS could not process the request.'
32
+ number = xml.xpath('//Error/Number')&.text
33
+ desc = xml.xpath('//Error/Description')&.text
34
+ [number, desc].select(&:present?).join(': ').presence&.strip || 'USPS could not process the request.'
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Usps
6
+ class SerializeAddressValidationRequest
7
+ def self.call(location:, login:)
8
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
9
+ xml.AddressValidateRequest USERID: login do
10
+ xml.Address do
11
+ xml.Address1 location.address2 # USPS swaps Address1 and Address2 in the request
12
+ xml.Address2 location.address1
13
+ xml.City location.city
14
+ xml.State location.region.code
15
+ xml.Zip5 location.zip
16
+ xml.Zip4
17
+ end
18
+ end
19
+ end
20
+ xml_builder.to_xml
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Usps
6
+ class SerializeCityStateLookupRequest
7
+ def self.call(location:, login:)
8
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
9
+ xml.CityStateLookupRequest(USERID: login) do
10
+ xml.ZipCode do
11
+ xml.Zip5 location.zip
12
+ end
13
+ end
14
+ end
15
+ xml_builder.to_xml
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -33,10 +33,10 @@ module FriendlyShipping
33
33
  container = CONTAINERS[package.properties[:box_name] || :rectangular]
34
34
  xml.Container(container)
35
35
  xml.Size(size_code)
36
- xml.Width("%0.2f" % package.width.convert_to(:inches).value.to_f)
37
- xml.Length("%0.2f" % package.length.convert_to(:inches).value.to_f)
38
- xml.Height("%0.2f" % package.height.convert_to(:inches).value.to_f)
39
- xml.Girth("%0.2f" % girth(package))
36
+ xml.Width("%<width>0.2f" % { width: package.width.convert_to(:inches).value.to_f })
37
+ xml.Length("%<length>0.2f" % { length: package.length.convert_to(:inches).value.to_f })
38
+ xml.Height("%<height>0.2f" % { height: package.height.convert_to(:inches).value.to_f })
39
+ xml.Girth("%<girth>0.2f" % { girth: girth(package) })
40
40
  xml.Machinable(machinable(package))
41
41
  end
42
42
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FriendlyShipping
4
- VERSION = "0.2.6"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: friendly_shipping
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Meyerhoff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-07-15 00:00:00.000000000 Z
11
+ date: 2019-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: data_uri
@@ -158,6 +158,8 @@ files:
158
158
  - bin/setup
159
159
  - friendly_shipping.gemspec
160
160
  - lib/friendly_shipping.rb
161
+ - lib/friendly_shipping/api_failure.rb
162
+ - lib/friendly_shipping/api_result.rb
161
163
  - lib/friendly_shipping/bad_request.rb
162
164
  - lib/friendly_shipping/carrier.rb
163
165
  - lib/friendly_shipping/label.rb
@@ -174,10 +176,14 @@ files:
174
176
  - lib/friendly_shipping/services/ship_engine/serialize_rate_estimate_request.rb
175
177
  - lib/friendly_shipping/services/ups.rb
176
178
  - lib/friendly_shipping/services/ups/client.rb
179
+ - lib/friendly_shipping/services/ups/parse_address_validation_response.rb
180
+ - lib/friendly_shipping/services/ups/parse_city_state_lookup_response.rb
177
181
  - lib/friendly_shipping/services/ups/parse_rate_response.rb
178
182
  - lib/friendly_shipping/services/ups/parse_xml_response.rb
179
183
  - lib/friendly_shipping/services/ups/serialize_access_request.rb
180
184
  - lib/friendly_shipping/services/ups/serialize_address_snippet.rb
185
+ - lib/friendly_shipping/services/ups/serialize_address_validation_request.rb
186
+ - lib/friendly_shipping/services/ups/serialize_city_state_lookup_request.rb
181
187
  - lib/friendly_shipping/services/ups/serialize_package_node.rb
182
188
  - lib/friendly_shipping/services/ups/serialize_rating_service_selection_request.rb
183
189
  - lib/friendly_shipping/services/ups/shipping_methods.rb
@@ -185,9 +191,13 @@ files:
185
191
  - lib/friendly_shipping/services/usps/choose_package_rate.rb
186
192
  - lib/friendly_shipping/services/usps/client.rb
187
193
  - lib/friendly_shipping/services/usps/machinable_package.rb
194
+ - lib/friendly_shipping/services/usps/parse_address_validation_response.rb
195
+ - lib/friendly_shipping/services/usps/parse_city_state_lookup_response.rb
188
196
  - lib/friendly_shipping/services/usps/parse_package_rate.rb
189
197
  - lib/friendly_shipping/services/usps/parse_rate_response.rb
190
198
  - lib/friendly_shipping/services/usps/parse_xml_response.rb
199
+ - lib/friendly_shipping/services/usps/serialize_address_validation_request.rb
200
+ - lib/friendly_shipping/services/usps/serialize_city_state_lookup_request.rb
191
201
  - lib/friendly_shipping/services/usps/serialize_rate_request.rb
192
202
  - lib/friendly_shipping/services/usps/shipping_methods.rb
193
203
  - lib/friendly_shipping/shipping_method.rb