friendly_shipping 0.2.6 → 0.3.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 (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