lib/active_shipping/carriers/usps.rb in active_shipping-1.0.0.pre2 vs lib/active_shipping/carriers/usps.rb in active_shipping-1.0.0.pre3

- old
+ new

@@ -166,19 +166,19 @@ /There is no record of that mail item/, /This Information has not been included in this Test Server\./, /Delivery status information is not available/ ] - ESCAPING_AND_SYMBOLS = /&amp;lt;\S*&amp;gt;/ - LEADING_USPS = /^USPS/ + ESCAPING_AND_SYMBOLS = /&lt;\S*&gt;/ + LEADING_USPS = /^USPS / TRAILING_ASTERISKS = /\*+$/ SERVICE_NAME_SUBSTITUTIONS = /#{ESCAPING_AND_SYMBOLS}|#{LEADING_USPS}|#{TRAILING_ASTERISKS}/ def find_tracking_info(tracking_number, options = {}) options = @options.update(options) tracking_request = build_tracking_request(tracking_number, options) - response = commit(:track, tracking_request, (options[:test] || false)) + response = commit(:track, tracking_request, options[:test] || false) parse_tracking_response(response, options) end def self.size_code_for(package) if package.inches(:max) <= 12 @@ -247,26 +247,28 @@ end protected def build_tracking_request(tracking_number, options = {}) - xml_request = XmlNode.new('TrackRequest', 'USERID' => @options[:login]) do |root_node| - root_node << XmlNode.new('TrackID', :ID => tracking_number) + xml_builder = Nokogiri::XML::Builder.new do |xml| + xml.TrackRequest('USERID' => @options[:login]) do + xml.TrackID('ID' => tracking_number) + end end - URI.encode(xml_request.to_s) + xml_builder.to_xml end def us_rates(origin, destination, packages, options = {}) request = build_us_rate_request(packages, origin.zip, destination.zip, options) # never use test mode; rate requests just won't work on test servers - parse_rate_response origin, destination, packages, commit(:us_rates, request, false), options + parse_rate_response(origin, destination, packages, commit(:us_rates, request, false), options) end def world_rates(origin, destination, packages, options = {}) request = build_world_rate_request(packages, destination, options) # never use test mode; rate requests just won't work on test servers - parse_rate_response origin, destination, packages, commit(:world_rates, request, false), options + parse_rate_response(origin, destination, packages, commit(:world_rates, request, false), options) end # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead. def canned_address_verification_works? return false unless @options[:login] @@ -281,12 +283,12 @@ <State>CA</State> <ZIP5>94110</ZIP5> <ZIP4>9411</ZIP4> </CarrierPickupAvailabilityRequest> EOF - xml = REXML::Document.new(commit(:test, URI.encode(request), true)) - xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'SAN FRANCISCO' && xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '18 FAIR AVE' + xml = Nokogiri.XML(commit(:test, request, true)) { |config| config.strict } + xml.at('/CarrierPickupAvailabilityResponse/City').text == 'SAN FRANCISCO' && xml.at('/CarrierPickupAvailabilityResponse/Address2').text == '18 FAIR AVE' end # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel, # :media, :library, :online, :plus, :all]. defaults to :all. # options[:books] -- Either true or false. Packages of books or other printed matter @@ -294,44 +296,45 @@ # package.options[:container] -- Can be :rectangular, :variable, or a flat rate container # defined in CONTAINERS. # package.options[:machinable] -- Either true or false. Overrides the detection of # "machinability" entirely. def build_us_rate_request(packages, origin_zip, destination_zip, options = {}) - packages = Array(packages) - request = XmlNode.new('RateV4Request', :USERID => @options[:login]) do |rate_request| - packages.each_with_index do |p, id| - rate_request << XmlNode.new('Package', :ID => id.to_s) do |package| - commercial_type = commercial_type(options) - default_service = DEFAULT_SERVICE[commercial_type] - service = options.fetch(:service, default_service).to_sym + xml_builder = Nokogiri::XML::Builder.new do |xml| + xml.RateV4Request('USERID' => @options[:login]) do + Array(packages).each_with_index do |package, id| + xml.Package('ID' => id) do + commercial_type = commercial_type(options) + default_service = DEFAULT_SERVICE[commercial_type] + service = options.fetch(:service, default_service).to_sym - if commercial_type && service != default_service - raise ArgumentError, "Commercial #{commercial_type} rates are only provided with the #{default_service.inspect} service." - end + if commercial_type && service != default_service + raise ArgumentError, "Commercial #{commercial_type} rates are only provided with the #{default_service.inspect} service." + end - package << XmlNode.new('Service', US_SERVICES[service]) - package << XmlNode.new('FirstClassMailType', FIRST_CLASS_MAIL_TYPES[options[:first_class_mail_type].try(:to_sym)]) - package << XmlNode.new('ZipOrigination', strip_zip(origin_zip)) - package << XmlNode.new('ZipDestination', strip_zip(destination_zip)) - package << XmlNode.new('Pounds', 0) - package << XmlNode.new('Ounces', "%0.1f" % [p.ounces, 1].max) - package << XmlNode.new('Container', CONTAINERS[p.options[:container]]) - package << XmlNode.new('Size', USPS.size_code_for(p)) - package << XmlNode.new('Width', "%0.2f" % p.inches(:width)) - package << XmlNode.new('Length', "%0.2f" % p.inches(:length)) - package << XmlNode.new('Height', "%0.2f" % p.inches(:height)) - package << XmlNode.new('Girth', "%0.2f" % p.inches(:girth)) - is_machinable = if p.options.has_key?(:machinable) - p.options[:machinable] ? true : false - else - USPS.package_machinable?(p) + xml.Service(US_SERVICES[service]) + xml.FirstClassMailType(FIRST_CLASS_MAIL_TYPES[options[:first_class_mail_type].try(:to_sym)]) + xml.ZipOrigination(strip_zip(origin_zip)) + xml.ZipDestination(strip_zip(destination_zip)) + xml.Pounds(0) + xml.Ounces("%0.1f" % [package.ounces, 1].max) + xml.Container(CONTAINERS[package.options[:container]]) + xml.Size(USPS.size_code_for(package)) + xml.Width("%0.2f" % package.inches(:width)) + xml.Length("%0.2f" % package.inches(:length)) + xml.Height("%0.2f" % package.inches(:height)) + xml.Girth("%0.2f" % package.inches(:girth)) + is_machinable = if package.options.has_key?(:machinable) + package.options[:machinable] ? true : false + else + USPS.package_machinable?(package) + end + xml.Machinable(is_machinable.to_s.upcase) end - package << XmlNode.new('Machinable', is_machinable.to_s.upcase) end end end - URI.encode(save_request(request.to_s)) + save_request(xml_builder.to_xml) end # important difference with international rate requests: # * services are not given in the request # * package sizes are not given in the request @@ -341,60 +344,61 @@ # # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope]. # Defaults to :package. def build_world_rate_request(packages, destination, options) country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name - request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request| - packages.each_index do |id| - p = packages[id] - rate_request << XmlNode.new('Package', :ID => id.to_s) do |package| - package << XmlNode.new('Pounds', 0) - package << XmlNode.new('Ounces', [p.ounces, 1].max.ceil) # takes an integer for some reason, must be rounded UP - package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package') - package << XmlNode.new('GXG') do |gxg| - gxg << XmlNode.new('POBoxFlag', destination.po_box? ? 'Y' : 'N') - gxg << XmlNode.new('GiftFlag', p.gift? ? 'Y' : 'N') + xml_builder = Nokogiri::XML::Builder.new do |xml| + xml.IntlRateV2Request('USERID' => @options[:login]) do + Array(packages).each_with_index do |package, id| + xml.Package('ID' => id) do + xml.Pounds(0) + xml.Ounces([package.ounces, 1].max.ceil) # takes an integer for some reason, must be rounded UP + xml.MailType(MAIL_TYPES[package.options[:mail_type]] || 'Package') + xml.GXG do + xml.POBoxFlag(destination.po_box? ? 'Y' : 'N') + xml.GiftFlag(package.gift? ? 'Y' : 'N') + end + + value = if package.value && package.value > 0 && package.currency && package.currency != 'USD' + 0.0 + else + (package.value || 0) / 100.0 + end + xml.ValueOfContents(value) + + xml.Country(country) + xml.Container(package.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR') + xml.Size(USPS.size_code_for(package)) + xml.Width("%0.2f" % [package.inches(:width), 0.01].max) + xml.Length("%0.2f" % [package.inches(:length), 0.01].max) + xml.Height("%0.2f" % [package.inches(:height), 0.01].max) + xml.Girth("%0.2f" % [package.inches(:girth), 0.01].max) + if commercial_type = commercial_type(options) + xml.public_send(COMMERCIAL_FLAG_NAME.fetch(commercial_type), 'Y') + end end - value = if p.value && p.value > 0 && p.currency && p.currency != 'USD' - 0.0 - else - (p.value || 0) / 100.0 - end - package << XmlNode.new('ValueOfContents', value) - package << XmlNode.new('Country') do |node| - node.cdata = country - end - package << XmlNode.new('Container', p.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR') - package << XmlNode.new('Size', USPS.size_code_for(p)) - package << XmlNode.new('Width', "%0.2f" % [p.inches(:width), 0.01].max) - package << XmlNode.new('Length', "%0.2f" % [p.inches(:length), 0.01].max) - package << XmlNode.new('Height', "%0.2f" % [p.inches(:height), 0.01].max) - package << XmlNode.new('Girth', "%0.2f" % [p.inches(:girth), 0.01].max) - if commercial_type = commercial_type(options) - package << XmlNode.new(COMMERCIAL_FLAG_NAME.fetch(commercial_type), 'Y') - end end end end - URI.encode(save_request(request.to_s)) + save_request(xml_builder.to_xml) end def parse_rate_response(origin, destination, packages, response, options = {}) success = true message = '' rate_hash = {} - xml = REXML::Document.new(response) + xml = Nokogiri.XML(response) - if error = xml.elements['/Error'] + if error = xml.at('/Error') success = false - message = error.elements['Description'].text + message = error.at('Description').text else - xml.elements.each('/*/Package') do |package| - if package.elements['Error'] + xml.root.xpath('Package').each do |package| + if package.at('Error') success = false - message = package.get_text('Error/Description').to_s + message = package.at('Error/Description').text break end end if success @@ -421,33 +425,32 @@ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request) end def rates_from_response_node(response_node, packages, options = {}) rate_hash = {} - return false unless (root_node = response_node.elements['/IntlRateV2Response | /RateV4Response']) + return false unless (root_node = response_node.at_xpath('/IntlRateV2Response | /RateV4Response')) commercial_type = commercial_type(options) service_node, service_code_node, service_name_node, rate_node = if root_node.name == 'RateV4Response' %w(Postage CLASSID MailService) << DOMESTIC_RATE_FIELD[commercial_type] else %w(Service ID SvcDescription) << INTERNATIONAL_RATE_FIELD[commercial_type] end - root_node.each_element('Package') do |package_node| - this_package = packages[package_node.attributes['ID'].to_i] + root_node.xpath('Package').each do |package_node| + this_package = packages[package_node['ID'].to_i] - package_node.each_element(service_node) do |service_response_node| - service_name = service_response_node.get_text(service_name_node).to_s + package_node.xpath(service_node).each do |service_response_node| + service_name = service_response_node.at(service_name_node).text service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '') - service_name.strip! # aggregate specific package rates into a service-centric RateEstimate # first package with a given service name will initialize these; # later packages with same service will add to them this_service = rate_hash[service_name] ||= {} - this_service[:service_code] ||= service_response_node.attributes[service_code_node] + this_service[:service_code] ||= service_response_node.attributes[service_code_node].value package_rates = this_service[:package_rates] ||= [] this_package_rate = {:package => this_package, :rate => Package.cents_from(rate_value(rate_node, service_response_node, commercial_type))} package_rates << this_package_rate if package_valid_for_service(this_package, service_response_node) @@ -455,13 +458,13 @@ end rate_hash end def package_valid_for_service(package, service_node) - return true if service_node.elements['MaxWeight'].nil? - max_weight = service_node.get_text('MaxWeight').to_s.to_f - name = service_node.get_text('SvcDescription | MailService').to_s.downcase + return true if service_node.at('MaxWeight').nil? + max_weight = service_node.at('MaxWeight').text.to_f + name = service_node.at_xpath('SvcDescription | MailService').text.downcase if name =~ /flat.rate.box/ # domestic or international flat rate box # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm return (package_valid_for_max_dimensions(package, :weight => max_weight, # domestic apparently has no weight restriction @@ -477,19 +480,19 @@ return package_valid_for_max_dimensions(package, :weight => max_weight, :length => 12.5, :width => 9.5, :height => 0.75) - elsif service_node.elements['MailService'] # domestic non-flat rates + elsif service_node.at('MailService') # domestic non-flat rates return true else # international non-flat rates # Some sample english that this is required to parse: # # 'Max. length 46", width 35", height 46" and max. length plus girth 108"' # 'Max. length 24", Max. length, height, depth combined 36"' # - sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s) + sentence = CGI.unescapeHTML(service_node.at('MaxDimensions').text) tokens = sentence.downcase.split(/[^\d]*"/).reject(&:empty?) max_dimensions = {:weight => max_weight} single_axis_values = [] tokens.each do |token| axis_sum = [/length/, /width/, /height/, /depth/].sum { |regex| (token =~ regex) ? 1 : 0 } @@ -523,28 +526,28 @@ package.inches(:length) + package.inches(:width) + package.inches(:height))) end def parse_tracking_response(response, options) actual_delivery_date, status = nil - xml = REXML::Document.new(response) - root_node = xml.elements['TrackResponse'] + xml = Nokogiri.XML(response) + root_node = xml.root success = response_success?(xml) message = response_message(xml) if success destination = nil shipment_events = [] - tracking_details = xml.elements.collect('*/*/TrackDetail') { |e| e } + tracking_details = xml.root.xpath('TrackInfo/TrackDetail') - tracking_summary = xml.elements.collect('*/*/TrackSummary') { |e| e }.first + tracking_summary = xml.root.at('TrackInfo/TrackSummary') tracking_details << tracking_summary - tracking_number = root_node.elements['TrackInfo'].attributes['ID'].to_s + tracking_number = xml.root.at('TrackInfo').attributes['ID'].value tracking_details.each do |event| - details = extract_event_details(event.get_text.to_s) + details = extract_event_details(event.text) shipment_events << ShipmentEvent.new(details.description, details.zoneless_time, details.location) if details.location end shipment_events = shipment_events.sort_by(&:time) @@ -565,11 +568,11 @@ :actual_delivery_date => actual_delivery_date ) end def track_summary_node(document) - document.elements['*/*/TrackSummary'] + document.root.xpath('TrackInfo/TrackSummary') end def error_description_node(document) STATUS_NODE_PATTERNS.each do |pattern| if node = document.elements[pattern] @@ -581,55 +584,54 @@ def response_status_node(document) track_summary_node(document) || error_description_node(document) end def has_error?(document) - !!document.elements['Error'] + !document.at('Error').nil? end def no_record?(document) summary_node = track_summary_node(document) if summary_node - summary = summary_node.get_text.to_s + summary = summary_node.text RESPONSE_ERROR_MESSAGES.detect { |re| summary =~ re } summary =~ /There is no record of that mail item/ || summary =~ /This Information has not been included in this Test Server\./ else false end end def tracking_info_error?(document) - document.elements['*/TrackInfo/Error'] + !document.root.at('TrackInfo/Error').nil? end def response_success?(document) !(has_error?(document) || no_record?(document) || tracking_info_error?(document)) end def response_message(document) - response_node = response_status_node(document) - response_node.get_text.to_s + response_status_node(document).text end def commit(action, request, test = false) ssl_get(request_url(action, request, test)) end def request_url(action, request, test) scheme = USE_SSL[action] ? 'https://' : 'http://' host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN resource = test ? TEST_RESOURCE : LIVE_RESOURCE - "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}" + "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{URI.encode(request)}" end def strip_zip(zip) zip.to_s.scan(/\d{5}/).first || zip end private def rate_value(rate_node, service_response_node, commercial_type) - service_response_node.get_text(rate_node).to_s.to_f + service_response_node.at(rate_node).try(:text).to_f end def commercial_type(options) if options[:commercial_plus] == true :plus