diff --git a/lib/usps.rb b/lib/usps.rb index e2cd078..909ef67 100644 --- a/lib/usps.rb +++ b/lib/usps.rb @@ -5,12 +5,12 @@ module USPS require 'usps/errors' require 'usps/configuration' - autoload :Client, 'usps/client' - autoload :Address, 'usps/address' - autoload :Request, 'usps/request' - autoload :VERSION, 'usps/version' - autoload :Response, 'usps/response' - autoload :TrackDetail, 'usps/track_detail' + autoload :Client, 'usps/client' + autoload :Address, 'usps/address' + autoload :Request, 'usps/request' + autoload :VERSION, 'usps/version' + autoload :Response, 'usps/response' + autoload :TrackDetail, 'usps/track_detail' class << self attr_writer :config diff --git a/lib/usps/request.rb b/lib/usps/request.rb index 96969a0..f24c12a 100644 --- a/lib/usps/request.rb +++ b/lib/usps/request.rb @@ -1,14 +1,17 @@ module USPS::Request - autoload :Base, 'usps/request/base' - autoload :ZipCodeLookup, 'usps/request/zip_code_lookup' + autoload :Base, 'usps/request/base' + autoload :ZipCodeLookup, 'usps/request/zip_code_lookup' autoload :CityAndStateLookup, 'usps/request/city_and_state_lookup' autoload :AddressStandardization, 'usps/request/address_standardization' # Delivery and Signature confirmation. # DeliveryConfirmationCertify and SignatureConfirmationCertify should be used for testing - autoload :DeliveryConfirmation, 'usps/request/delivery_confirmation' + autoload :DeliveryConfirmation, 'usps/request/delivery_confirmation' autoload :DeliveryConfirmationCertify, 'usps/request/delivery_confirmation_certify' - autoload :TrackingLookup, 'usps/request/tracking_lookup' - autoload :TrackingFieldLookup, 'usps/request/tracking_field_lookup' + autoload :TrackingLookup, 'usps/request/tracking_lookup' + autoload :TrackingFieldLookup, 'usps/request/tracking_field_lookup' + autoload :ShippingRatesLookup, 'usps/request/shipping_rates_lookup' + autoload :InternationalShippingRatesLookup, 'usps/request/international_shipping_rates_lookup' + autoload :Package, 'usps/request/package' end diff --git a/lib/usps/request/base.rb b/lib/usps/request/base.rb index 3c346aa..16079a8 100644 --- a/lib/usps/request/base.rb +++ b/lib/usps/request/base.rb @@ -35,7 +35,7 @@ def response_for(xml) end def build(&block) - builder = Builder::XmlMarkup.new(:indent => 0) + builder = Builder::XmlMarkup.new(:indent => 2) builder.tag!(self.class.tag, :USERID => USPS.config.username, &block) end end diff --git a/lib/usps/request/international_shipping_rates_lookup.rb b/lib/usps/request/international_shipping_rates_lookup.rb new file mode 100644 index 0000000..08ff306 --- /dev/null +++ b/lib/usps/request/international_shipping_rates_lookup.rb @@ -0,0 +1,42 @@ +module USPS::Request + + class InternationalShippingRatesLookup < Base + config( + :api => 'IntlRateV2', + :tag => 'IntlRateV2Request', + :secure => false, + :response => USPS::Response::InternationalShippingRatesLookup + ) + + def initialize(*packages) + @packages = packages.flatten + if @packages.none? + raise ArgumentError, 'A shipping rate lookup requires at least one package (USPS::Package)' + end + end + + def build + super do |xml| + xml.Revision 2 + @packages.each do |package| + xml.Package :ID => package.id do + xml.Pounds package.pounds + xml.Ounces package.ounces + xml.Machinable 'true' # for Service=ALL + xml.MailType package.mail_type + xml.ValueOfContents 103 + xml.Country package.country + xml.Container package.container + xml.Size package.size + xml.Width package.width + xml.Length package.length + xml.Height package.height + xml.Girth nil + xml.CommercialFlag 'N' + end + end + end + end + + end +end diff --git a/lib/usps/request/package.rb b/lib/usps/request/package.rb new file mode 100644 index 0000000..f83d6d3 --- /dev/null +++ b/lib/usps/request/package.rb @@ -0,0 +1,5 @@ +module USPS::Request::Package + autoload :Base, 'usps/request/package/base' + autoload :DomesticPackage, 'usps/request/package/domestic_package' + autoload :InternationalPackage, 'usps/request/package/international_package' +end diff --git a/lib/usps/request/package/base.rb b/lib/usps/request/package/base.rb new file mode 100644 index 0000000..ebb5acc --- /dev/null +++ b/lib/usps/request/package/base.rb @@ -0,0 +1,33 @@ +module USPS::Request::Package + class Base + attr_accessor :id + attr_accessor :pounds, :ounces + attr_accessor :container + attr_accessor :size + attr_accessor :width, :length, :height, :girth + + @@required = [:id, :pounds, :ounces, :size] + + def initialize(fields) + fields.each { |name, value| send("#{name}=", value) } + + yield self if block_given? + + if fields[:size] == 'LARGE' + [:container, :width, :length, :height].each do |field| + error "#{field} is required when Size=LARGE" unless send(field) + end + end + + @@required.each do |field| + error "#{field} is required" unless send(field) + end + end + + protected + + def error(message) + raise ArgumentError.new message + end + end +end diff --git a/lib/usps/request/package/domestic_package.rb b/lib/usps/request/package/domestic_package.rb new file mode 100644 index 0000000..40291b3 --- /dev/null +++ b/lib/usps/request/package/domestic_package.rb @@ -0,0 +1,18 @@ +module USPS::Request::Package + class DomesticPackage < Base + attr_accessor :service + attr_accessor :first_class_mail_type + attr_accessor :origin_zip, :destination_zip + attr_accessor :value + attr_accessor :amount_to_collect + + @@required += [:service, :origin_zip, :destination_zip] + + def initialize(fields = {}) + if fields[:service] == 'FIRST CLASS' and !fields[:first_class_mail_type] + error "first_class_mail_type is required when Service=FIRST Class" + end + super + end + end +end diff --git a/lib/usps/request/package/international_package.rb b/lib/usps/request/package/international_package.rb new file mode 100644 index 0000000..633394e --- /dev/null +++ b/lib/usps/request/package/international_package.rb @@ -0,0 +1,11 @@ +module USPS::Request::Package + class InternationalPackage < Base + attr_accessor :country, :mail_type + + @@required += [:country, :mail_type] + + def initialize(fields = {}) + super + end + end +end diff --git a/lib/usps/request/shipping_rates_lookup.rb b/lib/usps/request/shipping_rates_lookup.rb new file mode 100644 index 0000000..5cfb605 --- /dev/null +++ b/lib/usps/request/shipping_rates_lookup.rb @@ -0,0 +1,42 @@ +module USPS::Request + + class ShippingRatesLookup < Base + config( + :api => 'RateV4', + :tag => 'RateV4Request', + :secure => false, + :response => USPS::Response::ShippingRatesLookup + ) + + def initialize(*packages) + @packages = packages.flatten + if @packages.none? + raise ArgumentError, 'A shipping rate lookup requires at least one package (USPS::Package)' + end + end + + def build + super do |xml| + @packages.each do |package| + xml.Package :ID => package.id do + xml.Service package.service + xml.FirstClassMailType(package.first_class_mail_type) if package.first_class_mail_type + xml.ZipOrigination package.origin_zip + xml.ZipDestination package.destination_zip + xml.Pounds package.pounds + xml.Ounces package.ounces + xml.Container package.container + xml.Size package.size + if package.size == 'LARGE' + xml.Width package.width + xml.Length package.length + xml.Height package.height + end + xml.Machinable 'true' # for Service=ALL + end + end + end + end + + end +end diff --git a/lib/usps/response.rb b/lib/usps/response.rb index 467896a..8da2a4c 100644 --- a/lib/usps/response.rb +++ b/lib/usps/response.rb @@ -1,8 +1,11 @@ module USPS::Response - autoload :Base, 'usps/response/base' - autoload :CityAndStateLookup, 'usps/response/city_and_state_lookup' - autoload :DeliveryConfirmation, 'usps/response/delivery_confirmation' - autoload :AddressStandardization, 'usps/response/address_standardization' - autoload :TrackingLookup, 'usps/response/tracking_lookup' - autoload :TrackingFieldLookup, 'usps/response/tracking_field_lookup' + autoload :Base, 'usps/response/base' + autoload :CityAndStateLookup, 'usps/response/city_and_state_lookup' + autoload :DeliveryConfirmation, 'usps/response/delivery_confirmation' + autoload :AddressStandardization, 'usps/response/address_standardization' + autoload :TrackingLookup, 'usps/response/tracking_lookup' + autoload :TrackingFieldLookup, 'usps/response/tracking_field_lookup' + autoload :ShippingRatesLookup, 'usps/response/shipping_rates_lookup' + autoload :InternationalShippingRatesLookup, 'usps/response/international_shipping_rates_lookup' + autoload :Package, 'usps/response/package' end diff --git a/lib/usps/response/international_shipping_rates_lookup.rb b/lib/usps/response/international_shipping_rates_lookup.rb new file mode 100644 index 0000000..6c6cf5a --- /dev/null +++ b/lib/usps/response/international_shipping_rates_lookup.rb @@ -0,0 +1,27 @@ +module USPS::Response + class InternationalShippingRatesLookup < Base + attr_reader :packages + + def initialize(xml) + @packages = [] + xml.search('Package').each do |package_node| + @packages << Package::InternationalPackage.new do |package| + package.id = package_node.attr('ID') + package.services = package_node.search('Service').map do |postage| + parse_service(postage) + end + end + end + end + + private + + def parse_service(node) + Package::InternationalPackage::Service.new.tap do |service| + service.id = node.attr('ID') + service.description = node.search('SvcDescription').text + service.rate = node.search('Postage').text + end + end + end +end diff --git a/lib/usps/response/package.rb b/lib/usps/response/package.rb new file mode 100644 index 0000000..351d8b0 --- /dev/null +++ b/lib/usps/response/package.rb @@ -0,0 +1,4 @@ +module USPS::Response::Package + autoload :DomesticPackage, 'usps/response/package/domestic_package' + autoload :InternationalPackage, 'usps/response/package/international_package' +end diff --git a/lib/usps/response/package/domestic_package.rb b/lib/usps/response/package/domestic_package.rb new file mode 100644 index 0000000..3788790 --- /dev/null +++ b/lib/usps/response/package/domestic_package.rb @@ -0,0 +1,15 @@ +module USPS::Response::Package + class DomesticPackage + class Postage + attr_accessor :rate, :mail_service, :class_id + end + + attr_accessor :postages + attr_accessor :id, :origin_zip, :destination_zip, :pounds, :ounces, :container, :size + + def initialize(properties = {}) + properties.each_pair { |k, v| send("#{k}=", v) } + yield self if block_given? + end + end +end diff --git a/lib/usps/response/package/international_package.rb b/lib/usps/response/package/international_package.rb new file mode 100644 index 0000000..fb136c2 --- /dev/null +++ b/lib/usps/response/package/international_package.rb @@ -0,0 +1,18 @@ +module USPS::Response::Package + class InternationalPackage + class Service + attr_accessor :id, :description, :rate + end + + attr_accessor :id + attr_accessor :origin_zip, :destination_zip + attr_accessor :pounds, :ounces, :container, :size + + attr_accessor :services + + def initialize(properties = {}) + properties.each_pair { |k, v| send("#{k}=", v) } + yield self if block_given? + end + end +end diff --git a/lib/usps/response/shipping_rates_lookup.rb b/lib/usps/response/shipping_rates_lookup.rb new file mode 100644 index 0000000..a8d7ebc --- /dev/null +++ b/lib/usps/response/shipping_rates_lookup.rb @@ -0,0 +1,31 @@ +module USPS::Response + class ShippingRatesLookup < Base + attr_reader :packages + + def initialize(xml) + @packages = [] + xml.search('Package').each do |package_node| + @packages << Package::DomesticPackage.new do |package_response| + package_response.postages = package_node.search('Postage').map { |postage| parse_postage(postage) } + package_response.id = package_node.attr('ID') + package_response.pounds = package_node.search('Pounds').text + package_response.ounces = package_node.search('Ounces').text + package_response.size = package_node.search('Size').text + package_response.container = package_node.search('Container').text + package_response.origin_zip = package_node.search('ZipOrigination').text + package_response.destination_zip = package_node.search('ZipDestination').text + end + end + end + + private + + def parse_postage(node) + Package::DomesticPackage::Postage.new.tap do |postage| + postage.class_id = node.attr('CLASSID') + postage.mail_service = node.search('MailService').text + postage.rate = node.search('Rate').text + end + end + end +end diff --git a/lib/usps/response/tracking_field_lookup.rb b/lib/usps/response/tracking_field_lookup.rb index 3042fb5..651a9f0 100644 --- a/lib/usps/response/tracking_field_lookup.rb +++ b/lib/usps/response/tracking_field_lookup.rb @@ -14,7 +14,7 @@ def initialize(xml) @details << parse(detail) end end - + private def parse(node) USPS::TrackDetail.new( diff --git a/spec/data/shipping_rates_lookup.xml b/spec/data/shipping_rates_lookup.xml new file mode 100644 index 0000000..c56a1db --- /dev/null +++ b/spec/data/shipping_rates_lookup.xml @@ -0,0 +1,31 @@ + + + 20171 + 08540 + 2 + 0 + REGULAR + TRUE + 2 + + Express Mail&lt;sup&gt;&amp;reg;&lt;/sup&gt; + 17.40 + + + Express Mail&lt;sup&gt;&amp;reg;&lt;/sup&gt; Hold For Pickup + 17.40 + + + Express Mail&lt;sup&gt;&amp;reg;&lt;/sup&gt; Sunday/Holiday Delivery + 29.90 + + + Express Mail&lt;sup&gt;&amp;reg;&lt;/sup&gt; Flat Rate Boxes + 39.95 + + + Express Mail&lt;sup&gt;&amp;reg;&lt;/sup&gt; Flat Rate Boxes Hold For Pickup + 39.95 + + + diff --git a/spec/package_spec.rb b/spec/package_spec.rb new file mode 100644 index 0000000..76f3a98 --- /dev/null +++ b/spec/package_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe USPS::Request::Package::DomesticPackage do + + Package = USPS::Request::Package::DomesticPackage + + REQUIRED_PROPERTIES = { + :id => 3, + :service => 'PRIORITY', + :origin_zip => '20171', + :destination_zip => '08540', + :pounds => 5, + :ounces => 4, + :container => 'VARIABLE', + :size => 'LARGE' + } + + it "should be valid when all required properties are specified" do + expect { + package = Package.new(REQUIRED_PROPERTIES) + }.to_not raise_exception + end + + it "properties can be set in an initialization block" do + package = Package.new(REQUIRED_PROPERTIES) do |p| + p.size = "REGULAR" + end + package.size.should == "REGULAR" + end + + REQUIRED_PROPERTIES.keys.each do |prop| + it "requires #{prop}" do + expect { + package = Package.new(required_properties.dup.delete prop) + }.to raise_exception + end + end +end diff --git a/spec/request/international_shipping_rates_lookup_spec.rb b/spec/request/international_shipping_rates_lookup_spec.rb new file mode 100644 index 0000000..4250b5c --- /dev/null +++ b/spec/request/international_shipping_rates_lookup_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +module USPS::Request + describe InternationalShippingRatesLookup do + it "uses the IntlRateV2 API settings" do + InternationalShippingRatesLookup.tap do |klass| + klass.secure.should be_false + klass.api.should == 'IntlRateV2' + klass.tag.should == 'IntlRateV2Request' + end + end + + it "requires at least one package" do + expect { + request = InternationalShippingRatesLookup.new + }.to raise_exception(ArgumentError) + end + + let(:required_properties) do + { + :id => "42", + :country => "Japan", + :mail_type => "LargeEnvelope", + :pounds => 3, + :ounces => 5, + :container => 'RECTANGULAR', + :size => 'LARGE', + } + end + + def package_xml_from_attributes(attributes) + package = Package::InternationalPackage.new(attributes) + request = InternationalShippingRatesLookup.new(package) + Nokogiri::XML.parse(request.build) + end + + it "builds a valid XML request for USPS IntlRateV2" do + package_properties = required_properties.merge( + :height => 13, + :width => 10, + :length => 15 + ) + package_xml = package_xml_from_attributes(package_properties) + + package_xml.search('Package').count.should == 1 + first_package = package_xml.search('Package').first + first_package.attr('ID').should == "42" + first_package.search('Pounds').text.should == '3' + first_package.search('Ounces').text.should == '5' + first_package.search('Container').text.should == 'RECTANGULAR' + first_package.search('Size').text.should == 'LARGE' + first_package.search('Height').text.should == '13' + first_package.search('Width').text.should == '10' + first_package.search('Length').text.should == '15' + end + end +end diff --git a/spec/request/package/international_package_spec.rb b/spec/request/package/international_package_spec.rb new file mode 100644 index 0000000..b47c04d --- /dev/null +++ b/spec/request/package/international_package_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +module USPS::Request::Package + describe USPS::Request::Package::InternationalPackage do + VALID_PROPERTIES = { + :id => 3, + :country => "France", + :mail_type => "Package", + :pounds => 5, + :ounces => 4, + :container => 'RECTANGULAR', + :size => 'REGULAR' + } + + VALID_PROPERTIES.keys.each do |prop| + it "requires #{prop}" do + expect { + package = InternationalPackage.new(required_properties.dup.delete prop) + }.to raise_exception + end + end + + it "properties can be set with an initialization hash and/or block" do + package = InternationalPackage.new :id => 3, :country => "France" do |p| + p.mail_type = "FlatRate" + p.pounds = 4 + p.ounces = 0 + p.container = "RECTANGULAR" + p.size = "REGULAR" + end + + package.id.should == 3 + package.country.should == "France" + package.mail_type.should == "FlatRate" + package.pounds.should == 4 + package.ounces.should == 0 + package.container.should == "RECTANGULAR" + package.size.should == "REGULAR" + end + end +end diff --git a/spec/request/shipping_rates_lookup_spec.rb b/spec/request/shipping_rates_lookup_spec.rb new file mode 100644 index 0000000..1d5647a --- /dev/null +++ b/spec/request/shipping_rates_lookup_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe USPS::Request::ShippingRatesLookup do + it "uses the RateV4 API settings" do + USPS::Request::ShippingRatesLookup.tap do |klass| + klass.secure.should be_false + klass.api.should == 'RateV4' + klass.tag.should == 'RateV4Request' + end + end + + it "requires at least one package" do + expect { + request = USPS::Request::ShippingRatesLookup.new + }.to raise_exception(ArgumentError) + end + + let(:required_properties) do + { + :id => "42", + :service => "ALL", + :origin_zip => "20171", + :destination_zip => "08540", + :pounds => 5, + :ounces => 4, + :container => 'VARIABLE', + :size => 'LARGE', + } + end + + def package_xml_from_attributes(attributes) + package = USPS::Request::Package::DomesticPackage.new(attributes) + request = USPS::Request::ShippingRatesLookup.new(package) + Nokogiri::XML.parse(request.build) + end + + it "builds a valid XML request for USPS RateV4" do + package_properties = required_properties.merge( + :first_class_mail_type => 'PARCEL', + :height => 13, + :width => 10, + :length => 15 + ) + package_xml = package_xml_from_attributes(package_properties) + + package_xml.search('Package').count.should == 1 + first_package = package_xml.search('Package').first + first_package.attr('ID').should == "42" + first_package.search('Service').text.should == 'ALL' + first_package.search('FirstClassMailType').text.should == 'PARCEL' + first_package.search('ZipOrigination').text.should == '20171' + first_package.search('ZipDestination').text.should == '08540' + first_package.search('Pounds').text.should == '5' + first_package.search('Ounces').text.should == '4' + first_package.search('Container').text.should == 'VARIABLE' + first_package.search('Size').text.should == 'LARGE' + first_package.search('Height').text.should == '13' + first_package.search('Width').text.should == '10' + first_package.search('Length').text.should == '15' + end + + it "doesn't serialize dimension properties when package size is REGULAR" do + package_xml = package_xml_from_attributes(required_properties.merge :size => 'REGULAR') + first_package = package_xml.search('Package').first + + %w{Height Width Length}.each do |dimension| + first_package.search(dimension).should be_empty + end + end +end diff --git a/spec/response/shipping_rates_lookup_spec.rb b/spec/response/shipping_rates_lookup_spec.rb new file mode 100644 index 0000000..3de9be8 --- /dev/null +++ b/spec/response/shipping_rates_lookup_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe USPS::Response::ShippingRatesLookup do + + it "correctly parses a USPS RateV4 XML response" do + response = USPS::Response::ShippingRatesLookup.new(load_xml("shipping_rates_lookup.xml")) + response.should have(1).packages + response.packages.first.tap do |package| + package.should have(5).postages + package.id.should == '42' + package.origin_zip.should == '20171' + package.destination_zip.should == '08540' + package.pounds.should == '2' + package.ounces.should == '0' + package.size.should == 'REGULAR' + end + end +end