From e426c2ae4a395d96da3f38dee506dd85e7476d06 Mon Sep 17 00:00:00 2001 From: tducsai Date: Thu, 18 Jul 2013 05:12:15 -0700 Subject: [PATCH 01/10] initial mws-connect gem cloned --- .gitignore | 19 + .sublime-project | 19 + Gemfile | 4 + LICENSE.txt | 22 ++ README.md | 111 ++++++ Rakefile | 1 + lib/mws-connect.rb | 34 ++ lib/mws/apis.rb | 6 + lib/mws/apis/feeds.rb | 20 ++ lib/mws/apis/feeds/api.rb | 103 ++++++ lib/mws/apis/feeds/distance.rb | 23 ++ lib/mws/apis/feeds/feed.rb | 114 ++++++ lib/mws/apis/feeds/image_listing.rb | 44 +++ lib/mws/apis/feeds/inventory.rb | 77 ++++ lib/mws/apis/feeds/measurement.rb | 32 ++ lib/mws/apis/feeds/money.rb | 31 ++ lib/mws/apis/feeds/price_listing.rb | 48 +++ lib/mws/apis/feeds/product.rb | 173 +++++++++ lib/mws/apis/feeds/sale_price.rb | 31 ++ lib/mws/apis/feeds/shipping.rb | 160 +++++++++ lib/mws/apis/feeds/submission_info.rb | 45 +++ lib/mws/apis/feeds/submission_result.rb | 87 +++++ lib/mws/apis/feeds/transaction.rb | 37 ++ lib/mws/apis/feeds/weight.rb | 19 + lib/mws/apis/orders.rb | 23 ++ lib/mws/connection.rb | 84 +++++ lib/mws/enum.rb | 81 +++++ lib/mws/errors.rb | 32 ++ lib/mws/query.rb | 45 +++ lib/mws/serializer.rb | 81 +++++ lib/mws/signer.rb | 20 ++ lib/mws/utils.rb | 50 +++ mws-connect.gemspec | 26 ++ scripts/catalog-workflow | 136 +++++++ spec/mws/apis/feeds/api_spec.rb | 229 ++++++++++++ spec/mws/apis/feeds/distance_spec.rb | 43 +++ spec/mws/apis/feeds/feed_spec.rb | 92 +++++ spec/mws/apis/feeds/image_listing_spec.rb | 109 ++++++ spec/mws/apis/feeds/inventory_spec.rb | 135 +++++++ spec/mws/apis/feeds/measurement_spec.rb | 84 +++++ spec/mws/apis/feeds/money_spec.rb | 43 +++ spec/mws/apis/feeds/price_listing_spec.rb | 90 +++++ spec/mws/apis/feeds/product_spec.rb | 264 ++++++++++++++ spec/mws/apis/feeds/shipping_spec.rb | 78 +++++ spec/mws/apis/feeds/submission_info_spec.rb | 111 ++++++ spec/mws/apis/feeds/submission_result_spec.rb | 157 +++++++++ spec/mws/apis/feeds/transaction_spec.rb | 64 ++++ spec/mws/apis/feeds/weight_spec.rb | 43 +++ spec/mws/apis/orders_spec.rb | 9 + spec/mws/connection_spec.rb | 331 ++++++++++++++++++ spec/mws/enum_spec.rb | 166 +++++++++ spec/mws/query_spec.rb | 104 ++++++ spec/mws/serializer_spec.rb | 187 ++++++++++ spec/mws/signer_spec.rb | 67 ++++ spec/mws/utils_spec.rb | 147 ++++++++ spec/spec_helper.rb | 10 + 56 files changed, 4401 insertions(+) create mode 100644 .gitignore create mode 100644 .sublime-project create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 Rakefile create mode 100644 lib/mws-connect.rb create mode 100644 lib/mws/apis.rb create mode 100644 lib/mws/apis/feeds.rb create mode 100644 lib/mws/apis/feeds/api.rb create mode 100644 lib/mws/apis/feeds/distance.rb create mode 100644 lib/mws/apis/feeds/feed.rb create mode 100644 lib/mws/apis/feeds/image_listing.rb create mode 100644 lib/mws/apis/feeds/inventory.rb create mode 100644 lib/mws/apis/feeds/measurement.rb create mode 100644 lib/mws/apis/feeds/money.rb create mode 100644 lib/mws/apis/feeds/price_listing.rb create mode 100644 lib/mws/apis/feeds/product.rb create mode 100644 lib/mws/apis/feeds/sale_price.rb create mode 100644 lib/mws/apis/feeds/shipping.rb create mode 100644 lib/mws/apis/feeds/submission_info.rb create mode 100644 lib/mws/apis/feeds/submission_result.rb create mode 100644 lib/mws/apis/feeds/transaction.rb create mode 100644 lib/mws/apis/feeds/weight.rb create mode 100644 lib/mws/apis/orders.rb create mode 100644 lib/mws/connection.rb create mode 100644 lib/mws/enum.rb create mode 100644 lib/mws/errors.rb create mode 100644 lib/mws/query.rb create mode 100644 lib/mws/serializer.rb create mode 100644 lib/mws/signer.rb create mode 100644 lib/mws/utils.rb create mode 100644 mws-connect.gemspec create mode 100755 scripts/catalog-workflow create mode 100644 spec/mws/apis/feeds/api_spec.rb create mode 100644 spec/mws/apis/feeds/distance_spec.rb create mode 100644 spec/mws/apis/feeds/feed_spec.rb create mode 100644 spec/mws/apis/feeds/image_listing_spec.rb create mode 100644 spec/mws/apis/feeds/inventory_spec.rb create mode 100644 spec/mws/apis/feeds/measurement_spec.rb create mode 100644 spec/mws/apis/feeds/money_spec.rb create mode 100644 spec/mws/apis/feeds/price_listing_spec.rb create mode 100644 spec/mws/apis/feeds/product_spec.rb create mode 100644 spec/mws/apis/feeds/shipping_spec.rb create mode 100644 spec/mws/apis/feeds/submission_info_spec.rb create mode 100644 spec/mws/apis/feeds/submission_result_spec.rb create mode 100644 spec/mws/apis/feeds/transaction_spec.rb create mode 100644 spec/mws/apis/feeds/weight_spec.rb create mode 100644 spec/mws/apis/orders_spec.rb create mode 100644 spec/mws/connection_spec.rb create mode 100644 spec/mws/enum_spec.rb create mode 100644 spec/mws/query_spec.rb create mode 100644 spec/mws/serializer_spec.rb create mode 100644 spec/mws/signer_spec.rb create mode 100644 spec/mws/utils_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ef027c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +coverage +spec/reports +test/tmp +test/version_tmp +tmp +*.sublime-workspace diff --git a/.sublime-project b/.sublime-project new file mode 100644 index 0000000..d26bbe0 --- /dev/null +++ b/.sublime-project @@ -0,0 +1,19 @@ +{ + "folders": [ + { + "path": ".", + "folder_exclude_patterns": [ + "pkg", + "coverage" + ], + "file_exclude_patterns": [ + "*.sublime-project*", + ".gitignore" + ] + } + ], + "settings": { + "tab_size": 2, + "translate_tabs_to_spaces": true + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3097fd6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in mws.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f8a8a90 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2012 Sean M. Duncan, John E. Bailey + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a9bc04 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Mws + +The goal of this gem is to facilities interactions with the Amazon Marketplace Web Services from Ruby clients. + +## Installation + +Add this line to your application's Gemfile: + + gem 'mws-connect' + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install mws-connect + +## Usage + +Create Mws connection: + + require 'mws' + + mws = Mws.connect( + merchant: 'XXXXXXXXXXXXXX', + access: 'XXXXXXXXXXXXXXXXXXXXX', + secret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + ) + +Access the Feeds Api: + + feeds_api = mws.feeds + +Access the Feeds Api wrappers: + + products_api = mws.feeds.products + prices_api = mws.feeds.prices + images_api = mws.feeds.images + inventory_api = mws.feeds.inventory + +Example: Add product details: + + sku = '12345678' + product = Mws::Product sku { + upc '123435566654' + tax_code 'GEN_TAX_CODE' + name 'Some Product' + brand 'Some Brand' + msrp 19.99, 'USD' + manufacturer 'Some Manufacturer' + category :ce + details { + cable_or_adapter { + cable_length as_distance 5, :feet + } + } + } + + submission_id = mws.feeds.products.add(product) + +Example: Adding product images: + + image_submission_id = mws.feeds.images.add( + Mws::ImageListing(sku, 'http://url.to.product.iamges/main.jpg', 'Main'), + Mws::ImageListing(sku, 'http://url.to.product.iamges/pt1.jpg', 'PT1') + ) + +Example: Setting product pricing: + + price_submission_id = mws.feeds.prices.add( + Mws::PriceListing(sku, 14.99).on_sale(12.99, Time.now, 3.months.from_now) + ) + +Example: Overriding product shipping: + + sku = '12345678' + shipping_submission_id = mws.feeds.shipping.add( + Mws::Shipping sku { + replace 'UPS Ground', 4.99 + adjust '2nd-Day Air', 7.00 + } + ) + +Example: Setting product inventory: + + inventory_submission_id = mws.feeds.inventory.add( + Mws::Inventory(sku, quantity: 10, fulfillment_type: :mfn) + ) + +Example: Check the processing status of a feed: + + mws.feeds.list(id: submission_id).each do | info | + puts "SubmissionId: #{info.id} Status: #{info.status}" + puts "Complete!" if [:cancelled, :done].include? info.status + end + +Example: Get the results for a submission: + + result = mws.feeds.get(submission_id) + puts "Submission: #{result.transaction_id} - #{result.status}" + +_For an example of putting it all together check out the 'scripts/catalog-workflow'_ + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c702cfc --- /dev/null +++ b/Rakefile @@ -0,0 +1 @@ +require 'bundler/gem_tasks' diff --git a/lib/mws-connect.rb b/lib/mws-connect.rb new file mode 100644 index 0000000..285e93b --- /dev/null +++ b/lib/mws-connect.rb @@ -0,0 +1,34 @@ +require 'base64' +require 'openssl' + +module Mws + + autoload :Apis, 'mws/apis' + autoload :Connection, 'mws/connection' + autoload :Enum, 'mws/enum' + autoload :EnumEntry, 'mws/enum' + autoload :Errors, 'mws/errors' + autoload :Query, 'mws/query' + autoload :Serializer, 'mws/serializer' + autoload :Signer, 'mws/signer' + autoload :Utils, 'mws/utils' + + # The current version of this ruby gem + VERSION = '0.0.3' + + Utils.alias self, Apis::Feeds, + :Distance, + :Feed, + :ImageListing, + :Inventory, + :Money, + :PriceListing, + :Product, + :Shipping, + :Weight + + def self.connect(options) + Connection.new options + end + +end diff --git a/lib/mws/apis.rb b/lib/mws/apis.rb new file mode 100644 index 0000000..1b041d8 --- /dev/null +++ b/lib/mws/apis.rb @@ -0,0 +1,6 @@ +module Mws::Apis + + autoload :Feeds, 'mws/apis/feeds' + autoload :Orders, 'mws/apis/orders' + +end diff --git a/lib/mws/apis/feeds.rb b/lib/mws/apis/feeds.rb new file mode 100644 index 0000000..63061b3 --- /dev/null +++ b/lib/mws/apis/feeds.rb @@ -0,0 +1,20 @@ +module Mws::Apis::Feeds + + autoload :Api, 'mws/apis/feeds/api' + autoload :Distance, 'mws/apis/feeds/distance' + autoload :Feed, 'mws/apis/feeds/feed' + autoload :ImageListing, 'mws/apis/feeds/image_listing' + autoload :Inventory, 'mws/apis/feeds/inventory' + autoload :Measurement, 'mws/apis/feeds/measurement' + autoload :Money, 'mws/apis/feeds/money' + autoload :PriceListing, 'mws/apis/feeds/price_listing' + autoload :Product, 'mws/apis/feeds/product' + autoload :SalePrice, 'mws/apis/feeds/sale_price' + autoload :Shipping, 'mws/apis/feeds/shipping' + autoload :SubmissionInfo, 'mws/apis/feeds/submission_info' + autoload :SubmissionResult, 'mws/apis/feeds/submission_result' + autoload :TargetedApi, 'mws/apis/feeds/api' + autoload :Transaction, 'mws/apis/feeds/transaction' + autoload :Weight, 'mws/apis/feeds/weight' + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/api.rb b/lib/mws/apis/feeds/api.rb new file mode 100644 index 0000000..6ccb2df --- /dev/null +++ b/lib/mws/apis/feeds/api.rb @@ -0,0 +1,103 @@ +module Mws::Apis::Feeds + + class Api + + attr_reader :products, :images, :prices, :shipping, :inventory + + def initialize(connection, defaults={}) + raise Mws::Errors::ValidationError, 'A connection is required.' if connection.nil? + @connection = connection + defaults[:version] ||= '2009-01-01' + @defaults = defaults + + @products = self.for :product + @images = self.for :image + @prices = self.for :price + @shipping = self.for :override + @inventory = self.for :inventory + end + + def get(id) + node = @connection.get '/', { feed_submission_id: id }, @defaults.merge( + action: 'GetFeedSubmissionResult', + xpath: 'AmazonEnvelope/Message' + ) + SubmissionResult.from_xml node + end + + def submit(body, params) + params[:feed_type] = Feed::Type.for(params[:feed_type]).val + doc = @connection.post '/', params, body, @defaults.merge(action: 'SubmitFeed') + SubmissionInfo.from_xml doc.xpath('FeedSubmissionInfo').first + end + + def cancel(options={}) + + end + + def list(params={}) + params[:feed_submission_id] ||= params.delete(:ids) || [ params.delete(:id) ].flatten.compact + doc = @connection.get('/', params, @defaults.merge(action: 'GetFeedSubmissionList')) + doc.xpath('FeedSubmissionInfo').map do | node | + SubmissionInfo.from_xml node + end + end + + def count() + @connection.get('/', {}, @defaults.merge(action: 'GetFeedSubmissionCount')).xpath('Count').first.text.to_i + end + + def for(type) + TargetedApi.new self, @connection.merchant, type + end + + end + + class TargetedApi + + def initialize(feeds, merchant, type) + @feeds = feeds + @merchant = merchant + @message_type = Feed::Message::Type.for(type) + @feed_type = Feed::Type.for(type) + end + + def add(*resources) + submit resources, :update, true + end + + def update(*resources) + submit resources, :update + end + + def patch(*resources) + raise 'Operation Type not supported.' unless @feed_type == Feed::Type.PRODUCT + submit resources, :partial_update + end + + def delete(*resources) + submit resources, :delete + end + + def submit(resources, def_operation_type=nil, purge_and_replace=false) + messages = [] + feed = Feed.new @merchant, @message_type do + resources.each do | resource | + operation_type = def_operation_type + if resource.respond_to?(:operation_type) and resource.operation_type + operation_type = resource.operation_type + end + messages << message(resource, operation_type) + end + end + Transaction.new @feeds.submit(feed.to_xml, feed_type: @feed_type, purge_and_replace: purge_and_replace) do + messages.each do | message | + item message.id, message.resource.sku, message.operation_type, + message.resource.respond_to?(:type) ? message.resource.type : nil + end + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/distance.rb b/lib/mws/apis/feeds/distance.rb new file mode 100644 index 0000000..548c854 --- /dev/null +++ b/lib/mws/apis/feeds/distance.rb @@ -0,0 +1,23 @@ +module Mws::Apis::Feeds + + class Distance < Measurement + + Unit = Mws::Enum.for( + inches: 'inches', + feet: 'feet', + meters: 'meters', + decimeters: 'decimeters', + centimeters:'centimeters', + millimeters:'millimeters', + micrometers: 'micrometers', + nanometers: 'nanometers', + picometers: 'picometers' + ) + + def initialize(amount, unit=nil) + super amount, unit || :feet + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/feed.rb b/lib/mws/apis/feeds/feed.rb new file mode 100644 index 0000000..01237dd --- /dev/null +++ b/lib/mws/apis/feeds/feed.rb @@ -0,0 +1,114 @@ +require 'nokogiri' + +module Mws::Apis::Feeds + + class Feed + + Type = Mws::Enum.for( + product: '_POST_PRODUCT_DATA_', + product_relationship: '_POST_PRODUCT_RELATIONSHIP_DATA_', + item: '_POST_ITEM_DATA_', + override: '_POST_PRODUCT_OVERRIDES_DATA_', + image: '_POST_PRODUCT_IMAGE_DATA_', + price: '_POST_PRODUCT_PRICING_DATA_', + inventory: '_POST_INVENTORY_AVAILABILITY_DATA_', + order_acknowledgement: '_POST_ORDER_ACKNOWLEDGEMENT_DATA_', + order_fufillment: '_POST_ORDER_FULFILLMENT_DATA_', + fulfillment_order_request: '_POST_FULFILLMENT_ORDER_REQUEST_DATA_', + fulfillment_order_cancellation: '_POST_FULFILLMENT_ORDER_CANCELLATION_REQUEST_DATA' + ) + + attr_reader :merchant, :purge_and_replace + + Mws::Enum.sym_reader self, :message_type + + def initialize(merchant, message_type, purge_and_replace=false, &block) + @merchant = merchant + raise Mws::Errors::ValidationError, 'Merchant identifier is required.' if @merchant.nil? + @message_type = Message::Type.for(message_type) + raise Mws::Errors::ValidationError, 'A valid message type is required.' if @message_type.nil? + @purge_and_replace = purge_and_replace + @messages = [] + Builder.new(self, @messages).instance_eval &block if block_given? + end + + def messages + @messages.dup + end + + def to_xml + Nokogiri::XML::Builder.new do | xml | + xml.AmazonEnvelope('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:noNamespaceSchemaLocation' => 'amznenvelope.xsd') { + xml.Header { + xml.DocumentVersion '1.01' + xml.MerchantIdentifier @merchant + } + xml.MessageType @message_type.val + xml.PurgeAndReplace @purge_and_replace + @messages.each do | message | + message.to_xml xml + end + } + end.to_xml + end + + class Builder + + def initialize(feed, messages) + @feed = feed + @messages = messages + end + + def message(resource, operation_type=nil) + (@messages << Message.new(@messages.length + 1, @feed.message_type, resource, operation_type)).last + end + + end + + class Message + + Type = Mws::Enum.for( + fufillment_center: 'FulfillmentCenter', + inventory: 'Inventory', + listings: 'Listings', + order_acknowledgement: 'OrderAcknowledgement', + order_adjustment: 'OrderAdjustment', + order_fulfillment: 'OrderFulfillment', + override: 'Override', + price: 'Price', + processing_report: 'ProcessingReport', + product: 'Product', + image: 'ProductImage', + relationship: 'Relationship', + settlement_report: 'SettlementReport' + ) + + OperationType = Mws::Enum.for( + update: 'Update', + delete: 'Delete', + partial_update: 'PartialUpdate' + ) + + attr_reader :id, :resource + + Mws::Enum.sym_reader self, :type, :operation_type + + def initialize(id, type, resource, operation_type) + @id = id + @type = Type.for(type) + @resource = resource + @operation_type = OperationType.for(operation_type) || OperationType.UPDATE + end + + def to_xml(parent) + Mws::Serializer.tree 'Message', parent do | xml | + xml.MessageID @id + xml.OperationType @operation_type.val + @resource.to_xml @type.val, xml + end + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/image_listing.rb b/lib/mws/apis/feeds/image_listing.rb new file mode 100644 index 0000000..92fd9ac --- /dev/null +++ b/lib/mws/apis/feeds/image_listing.rb @@ -0,0 +1,44 @@ +require 'open-uri' + +module Mws::Apis::Feeds + + class ImageListing + + Type = Mws::Enum.for( + main: 'Main', + alt1: 'PT1', + alt2: 'PT2', + alt3: 'PT3', + alt4: 'PT4', + alt5: 'PT5' + ) + + attr_reader :sku, :url + + Mws::Enum.sym_reader self, :type + + def initialize(sku, url, type=nil) + raise Mws::Errors::ValidationError, 'SKU is required.' if sku.nil? or sku.strip.empty? + @sku = sku + raise Mws::Errors::ValidationError, 'URL must be an unsecured http address.' unless url =~ URI::regexp('http') + @url = url + @type = Type.for(type) || Type.MAIN + end + + def ==(other) + return true if equal? other + return false unless other.class == self.class + sku == other.sku and url == other.url and type == other.type + end + + def to_xml(name='ProductImage', parent=nil) + Mws::Serializer.tree name, parent do | xml | + xml.SKU @sku + xml.ImageType @type.val + xml.ImageLocation @url + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/inventory.rb b/lib/mws/apis/feeds/inventory.rb new file mode 100644 index 0000000..d9bc086 --- /dev/null +++ b/lib/mws/apis/feeds/inventory.rb @@ -0,0 +1,77 @@ +module Mws::Apis::Feeds + + class Inventory + + attr_reader :sku, :available, :quantity, :lookup, :fulfillment, :restock + + def initialize(sku, options) + @sku = sku + @available = options[:available] + @quantity = options[:quantity] + @lookup = options[:lookup] + @fulfillment = Fulfillment.new( + options[:fulfillment_center], + options[:fulfillment_latency], + options[:fulfillment_type] + ) + @restock = options[:restock] + validate + end + + def to_xml(name='Inventory', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.SKU @sku + xml.FulfillmentCenterID @fulfillment.center unless @fulfillment.center.nil? + xml.Available @available unless @available.nil? + xml.Quantity @quantity unless @quantity.nil? + xml.Lookup @lookup unless @lookup.nil? + xml.RestockDate @restock.iso8601 unless @restock.nil? + xml.FulfillmentLatency @fulfillment.latency unless @fulfillment.latency.nil? + xml.SwitchFulfillmentTo Fulfillment::Type.for(@fulfillment.type).val unless @fulfillment.type.nil? + end + end + + class Fulfillment + + Type = Mws::Enum.for afn: 'AFN', mfn: 'MFN' + + attr_reader :center, :latency + + Mws::Enum.sym_reader self, :type + + def initialize(center, latency, type) + @center = center + @latency = latency + unless @latency.nil? or (@latency.to_i == @latency and @latency > 0) + raise Mws::Errors::ValidationError.new('Fulfillment latency must be a whole number greater than zero.') + end + @type = Type.for(type) + raise Mws::Errors::ValidationError.new("Fulfillment type must be either 'AFN' or 'MFN'.") if type and @type.nil? + end + + end + + private + + def validate + raise Mws::Errors::ValidationError.new('SKU is required.') if @sku.nil? or @sku.to_s.strip.empty? + unless [ @available, @quantity, @lookup ].compact.size == 1 + raise Mws::Errors::ValidationError.new("One and only one of 'available', 'quantity' or 'lookup' must be specified.") + end + unless @available.nil? or [ true, false ].include? @available + raise Mws::Errors::ValidationError.new('Available must be either true or false.') + end + unless @quantity.nil? or (@quantity.to_i == @quantity and @quantity >= 0) + raise Mws::Errors::ValidationError.new('Quantity must be a whole number greater than or equal to zero.') + end + unless @lookup.nil? or [ true, false ].include? @lookup + raise Mws::Errors::ValidationError.new('Lookup must be either true or false.') + end + unless @restock.nil? or (@restock.respond_to? :iso8601 and Time.now < @restock) + raise Mws::Errors::ValidationError.new('Restock date must be in the future.') + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/measurement.rb b/lib/mws/apis/feeds/measurement.rb new file mode 100644 index 0000000..be9f6be --- /dev/null +++ b/lib/mws/apis/feeds/measurement.rb @@ -0,0 +1,32 @@ +module Mws::Apis::Feeds + + class Measurement + + attr_reader :amount + + Mws::Enum.sym_reader self, :unit + + def initialize(amount, unit) + @amount = amount + @unit = self.class.const_get(:Unit).for(unit) + raise Mws::Errors::ValidationError, "Invalid unit of measure '#{unit}'" if @unit.nil? + + end + + def ==(other) + return true if equal? other + return false unless other.class == self.class + amount == other.amount and unit == other.unit + end + + def to_xml(name=nil, parent=nil) + name ||= self.class.name.split('::').last + amount = @amount + amount = '%.2f' % amount if amount.to_s =~ /\d*\.\d\d+/ + Mws::Serializer.leaf name, parent, amount, unitOfMeasure: @unit.val + end + + end + +end + diff --git a/lib/mws/apis/feeds/money.rb b/lib/mws/apis/feeds/money.rb new file mode 100644 index 0000000..50ac0e8 --- /dev/null +++ b/lib/mws/apis/feeds/money.rb @@ -0,0 +1,31 @@ +module Mws::Apis::Feeds + + class Money < Measurement + + Currency = Mws::Enum.for( + usd: 'USD', + gbp: 'GBP', + eur: 'EUR', + jpy: 'JPY', + cad: 'CAD', + default: 'DEFAULT' + ) + + Unit = Currency + + def initialize(amount, currency=nil) + raise Mws::Errors::ValidationError, "Invalid currency '#{currency}'" if currency and Currency.for(currency).nil? + super amount, currency || :usd + end + + def currency + unit + end + + def to_xml(name='Price', parent=nil) + Mws::Serializer.leaf name, parent, '%.2f' % @amount, currency: @unit.val + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/price_listing.rb b/lib/mws/apis/feeds/price_listing.rb new file mode 100644 index 0000000..435f4d1 --- /dev/null +++ b/lib/mws/apis/feeds/price_listing.rb @@ -0,0 +1,48 @@ +require 'nokogiri' + +module Mws::Apis::Feeds + + class PriceListing + + attr_reader :sku, :currency, :base, :sale, :min + + def initialize(sku, base, options={}) + @sku = sku + @base = Money.new(base, options[:currency]) + @currency = @base.currency + @min = Money.new(options[:min], @currency) if options.include? :min + on_sale(options[:sale][:amount], options[:sale][:from], options[:sale][:to]) if options.include? :sale + validate + end + + def on_sale(amount, from, to) + @sale = SalePrice.new Money.new(amount, @currency), from, to + validate + self + end + + def to_xml(name='Price', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.SKU @sku + @base.to_xml 'StandardPrice', xml + @min.to_xml 'MAP', xml if @min + @sale.to_xml 'Sale', xml if @sale + end + end + + private + + def validate + if @min + unless @min.amount < @base.amount + raise Mws::Errors::ValidationError, "'Base Price' must be greater than 'Minimum Advertised Price'." + end + if @sale and @sale.price.amount <= @min.amount + raise Mws::Errors::ValidationError, "'Sale Price' must be greater than 'Minimum Advertised Price'." + end + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/product.rb b/lib/mws/apis/feeds/product.rb new file mode 100644 index 0000000..3039596 --- /dev/null +++ b/lib/mws/apis/feeds/product.rb @@ -0,0 +1,173 @@ +module Mws::Apis::Feeds + + class Product + + CategorySerializer = Mws::Serializer.new ce: 'CE', fba: 'FBA', eu_compliance: 'EUCompliance' + + attr_reader :sku, :description + + attr_accessor :upc, :tax_code, :msrp, :brand, :manufacturer, :name, :description, :bullet_points + attr_accessor :item_dimensions, :package_dimensions, :package_weight, :shipping_weight + attr_accessor :category, :details + + def initialize(sku, &block) + @sku = sku + @bullet_points = [] + ProductBuilder.new(self).instance_eval &block if block_given? + raise Mws::Errors::ValidationError, 'Product must have a category when details are specified.' if @details and @category.nil? + end + + def to_xml(name='Product', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.SKU @sku + xml.StandardProductID { + xml.Type 'UPC' + xml.Value @upc + } unless @upc.nil? + xml.ProductTaxCode @tax_code unless @upc.nil? + xml.DescriptionData { + xml.Title @name unless @name.nil? + xml.Brand @brand unless @brand.nil? + xml.Description @description unless @description.nil? + bullet_points.each do | bullet_point | + xml.BulletPoint bullet_point + end + @item_dimensions.to_xml('ItemDimensions', xml) unless @item_dimensions.nil? + @package_dimensions.to_xml('PackageDimensions', xml) unless @item_dimensions.nil? + + @package_weight.to_xml('PackageWeight', xml) unless @package_weight.nil? + @shipping_weight.to_xml('ShippingWeight', xml) unless @shipping_weight.nil? + + @msrp.to_xml 'MSRP', xml unless @msrp.nil? + + xml.Manufacturer @manufacturer unless @manufacturer.nil? + } + + unless @details.nil? + xml.ProductData { + CategorySerializer.xml_for @category, {product_type: @details}, xml + } + end + end + end + + class DelegatingBuilder + + def initialize(delegate) + @delegate = delegate + end + + def method_missing(method, *args, &block) + @delegate.send("#{method}=", *args, &block) if @delegate.respond_to? "#{method}=" + end + end + + class ProductBuilder < DelegatingBuilder + + def initialize(product) + super product + @product = product + end + + def msrp(amount, currency) + @product.msrp = Money.new amount, currency + end + + def item_dimensions(&block) + @product.item_dimensions = Dimensions.new + DimensionsBuilder.new(@product.item_dimensions).instance_eval &block if block_given? + end + + def package_dimensions(&block) + @product.package_dimensions = Dimensions.new + DimensionsBuilder.new(@product.package_dimensions).instance_eval &block if block_given? + end + + def package_weight(value, unit=nil) + @product.package_weight = Weight.new(value, unit) + end + + def shipping_weight(value, unit=nil) + @product.shipping_weight = Weight.new(value, unit) + end + + def bullet_point(bullet_point) + @product.bullet_points << bullet_point + end + + def details(details=nil, &block) + @product.details = details || {} + DetailBuilder.new(@product.details).instance_eval &block if block_given? + end + + end + + class Dimensions + + attr_accessor :length, :width, :height, :weight + + def to_xml(name='Dimensions', parent=nil) + Mws::Serializer.tree name, parent do |xml| + @length.to_xml 'Length', xml unless @length.nil? + @width.to_xml 'Width', xml unless @width.nil? + @height.to_xml 'Height', xml unless @height.nil? + @weight.to_xml 'Weight', xml unless @weight.nil? + end + end + + end + + class DimensionsBuilder + + def initialize(dimensions) + @dimensions = dimensions + end + + def length(value, unit=nil) + @dimensions.length = Distance.new(value, unit) + end + + def width(value, unit=nil) + @dimensions.width = Distance.new(value, unit) + end + + def height(value, unit=nil) + @dimensions.height = Distance.new(value, unit) + end + + def weight(value, unit=nil) + @dimensions.weight = Weight.new(value, unit) + end + end + + class DetailBuilder + + def initialize(details) + @details = details + end + + def as_distance(amount, unit=nil) + Distance.new amount, unit + end + + def as_weight(amount, unit=nil) + Weight.new amount, unit + end + + def as_money(amount, currency=nil) + Money.new amount, currency + end + + def method_missing(method, *args, &block) + if block_given? + @details[method] = {} + DetailBuilder.new(@details[method]).instance_eval(&block) + elsif args.length > 0 + @details[method] = args[0] + end + end + + end + + end +end diff --git a/lib/mws/apis/feeds/sale_price.rb b/lib/mws/apis/feeds/sale_price.rb new file mode 100644 index 0000000..9ef14ba --- /dev/null +++ b/lib/mws/apis/feeds/sale_price.rb @@ -0,0 +1,31 @@ +require 'nokogiri' + +module Mws::Apis::Feeds + + class SalePrice + + attr_reader :price, :from, :to + + def initialize(price, from, to) + @price = price + @from = from + @to = to + end + + def ==(other) + return true if equal? other + return false unless other.class == self.class + @price == other.price and @from == other.from and @to == other.to + end + + def to_xml(name='Sale', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.StartDate @from.iso8601 + xml.EndDate @to.iso8601 + price.to_xml 'SalePrice', xml + end + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/shipping.rb b/lib/mws/apis/feeds/shipping.rb new file mode 100644 index 0000000..8538fcf --- /dev/null +++ b/lib/mws/apis/feeds/shipping.rb @@ -0,0 +1,160 @@ +require 'nokogiri' + +module Mws::Apis::Feeds + + class Shipping + + Region = Mws::Enum.for( + continental_us: 'Cont US', + us_protectorates: 'US Prot', + alaska_hawaii: 'Alaska Hawaii', + apo_fpo: 'APO/FPO', + canada: 'Canada', + europe: 'Europe', + asia: 'Asia', + other: 'Outside US, EU, CA, Asia' + ) + + Variant = Mws::Enum.for( + street: 'Street Addr', + po_box: 'PO Box' + ) + + Speed = Mws::Enum.for( + standard: 'Std', + expedited: 'Exp', + two_day: 'Second', + one_day: 'Next' + ) + + attr_reader :sku + + def initialize(sku, &block) + raise Mws::Errors::ValidationError.new('SKU is required.') if sku.nil? or sku.to_s.strip.empty? + @sku = sku + @options = [] + Builder.new(self).instance_eval &block if block_given? + end + + def options + @options.dup + end + + def <<(option) + @options << option + end + + def to_xml(name='Override', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.SKU @sku + @options.each { |option| option.to_xml 'ShippingOverride', xml } + end + end + + class Option + + Mws::Enum.sym_reader self, :region, :speed, :variant + + def initialize(region, speed=Speed.STANDARD, variant=nil) + @region = Region.for(region) + @speed = Speed.for(speed) + @variant = nil + if supports_variant? + @variant = Variant.for(variant) || Variant.STREET + end + end + + def supports_variant? + [ Region.CONTINENTAL_US, Region.US_PROTECTORATES, Region.ALASKA_HAWAII, Region.APO_FPO ].include? @region + end + + def to_s + return @speed.val if [ Speed.TWO_DAY, Speed.ONE_DAY ].include? @speed + [ @speed, @region, @variant ].compact.map { |it| it.val }.join ' ' + end + + end + + class Restriction + + attr_reader :option, :restricted + + def initialize(option, restricted=true) + @option = option + @restricted = restricted + end + + def to_xml(name='ShippingOverride', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.ShipOption @option + xml.IsShippingRestricted @restricted + end + end + + end + + class Override + + Type = Mws::Enum.for( + adjust: 'Additive', + replace: 'Exclusive' + ) + + attr_reader :option, :amount + + Mws::Enum.sym_reader self, :type + + def initialize(option, type, amount) + @option = option + @type = Type.for(type) + @amount = amount + end + + def to_xml(name='ShippingOverride', parent=nil) + Mws::Serializer.tree name, parent do |xml| + xml.ShipOption @option + xml.Type @type.val + @amount.to_xml 'ShipAmount', xml + end + end + + end + + class Builder + + @target + + def initialize(target) + @target = target + end + + def restriction(restricted, region, speed, variant) + @target << Restriction.new(Option.new(region, speed, variant), restricted) + end + + def restricted(region, speed, variant=nil) + restriction true, region, speed, variant + end + + def unrestricted(region, speed, variant=nil) + restriction false, region, speed, variant + end + + def override(type, amount, currency, region, speed, variant) + @target << Override.new(Option.new(region, speed, variant), type, + Money.new(amount, currency)) + end + + def adjust(amount, currency, region, speed, variant=nil) + override :adjust, amount, currency, region, speed, variant + end + + def replace(amount, currency, region, speed, variant=nil) + override :replace, amount, currency, region, speed, variant + end + + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/submission_info.rb b/lib/mws/apis/feeds/submission_info.rb new file mode 100644 index 0000000..fe87839 --- /dev/null +++ b/lib/mws/apis/feeds/submission_info.rb @@ -0,0 +1,45 @@ +module Mws::Apis::Feeds + + class SubmissionInfo + + private :initialize + + private_class_method :new + + Status = Mws::Enum.for( + done: '_DONE_', + submitted: '_SUBMITTED_', + in_progress: '_IN_PROGRESS_', + cancelled: '_CANCELLED_' + ) + + attr_accessor :id, :submitted, :started, :completed + + Mws::Enum.sym_reader self, :type, :status + + def initialize(node) + @id = node.xpath('FeedSubmissionId').first.text.to_s + @type = Feed::Type.for(node.xpath('FeedType').first.text) + @status = Status.for(node.xpath('FeedProcessingStatus').first.text) + @submitted = Time.parse(node.xpath('SubmittedDate').first.text.to_s) + node.xpath('StartedProcessingDate').each do | node | + @started = Time.parse(node.text.to_s) + end + node.xpath('CompletedProcessingDate').each do | node | + @completed = Time.parse(node.text.to_s) + end + end + + def self.from_xml(node) + new node + end + + def ==(other) + return true if equal? other + return false unless other.class == self.class + id == other.id + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/submission_result.rb b/lib/mws/apis/feeds/submission_result.rb new file mode 100644 index 0000000..8290b4f --- /dev/null +++ b/lib/mws/apis/feeds/submission_result.rb @@ -0,0 +1,87 @@ +module Mws::Apis::Feeds + + class SubmissionResult + + private :initialize + + private_class_method :new + + Status = Mws::Enum.for( + complete: 'Complete', + processing: 'Processing', + rejected: 'Rejected' + ) + + attr_reader :transaction_id, :messages_processed + + Mws::Enum.sym_reader self, :status + + def initialize(node) + @transaction_id = node.xpath('ProcessingReport/DocumentTransactionID').first.text.to_s + @status = Status.for(node.xpath('ProcessingReport/StatusCode').first.text) + @messages_processed = node.xpath('ProcessingReport/ProcessingSummary/MessagesProcessed').first.text.to_i + + @counts = {} + [ Response::Type.SUCCESS, Response::Type.ERROR, Response::Type.WARNING ].each do | type | + @counts[type.sym] = node.xpath("ProcessingReport/ProcessingSummary/#{type.val.first}").first.text.to_i + end + @responses = {} + node.xpath('ProcessingReport/Result').each do | result_node | + response = Response.from_xml(result_node) + @responses[response.id.to_sym] = response + end + end + + def self.from_xml(node) + new node + end + + def ==(other) + return true if equal? other + return false unless other.class == self.class + transaction_id == other.transaction_id + end + + def count_for(type) + @counts[Response::Type.for(type).sym] + end + + def response_for(message_id) + @responses[message_id.to_s.to_sym] + end + + class Response + + Type = Mws::Enum.for( + success: [ 'MessagesSuccessful' ], + error: [ 'MessagesWithError', 'Error' ], + warning: [ 'MessagesWithWarning', 'Warning' ] + ) + + private :initialize + + private_class_method :new + + attr_reader :id, :code, :description, :additional_info + + Mws::Enum.sym_reader self, :type + + def initialize(node) + @id = node.xpath('MessageID').first.text.to_s + @type = Type.for(node.xpath('ResultCode').first.text.to_s) + @code = node.xpath('ResultMessageCode').first.text.to_i + @description = node.xpath('ResultDescription').first.text.to_s + node.xpath('AdditionalInfo').each do | info | + @additional_info = Mws::Serializer.new.hash_for(info, 'additional_info') + end + end + + def self.from_xml(node) + new node + end + + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/transaction.rb b/lib/mws/apis/feeds/transaction.rb new file mode 100644 index 0000000..11419d6 --- /dev/null +++ b/lib/mws/apis/feeds/transaction.rb @@ -0,0 +1,37 @@ +module Mws::Apis::Feeds + + class Transaction + + attr_reader :id, :type, :status, :submitted, :items + + def initialize(submission_info, items=[], &item_builder) + @id = submission_info.id + @type = submission_info.type + @status = submission_info.status + @submitted = submission_info.submitted + @items = items + instance_eval &item_builder unless item_builder.nil? + end + + private + + def item(id, sku, operation, qualifier=nil) + @items << Item.new(id, sku, operation, qualifier) + end + + class Item + + attr_reader :id, :sku, :operation, :qualifier + + def initialize(id, sku, operation, qualifier) + @id = id + @sku = sku + @operation = operation + @qualifier = qualifier + end + + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/feeds/weight.rb b/lib/mws/apis/feeds/weight.rb new file mode 100644 index 0000000..065541f --- /dev/null +++ b/lib/mws/apis/feeds/weight.rb @@ -0,0 +1,19 @@ +module Mws::Apis::Feeds + + class Weight < Measurement + + Unit = Mws::Enum.for( + grams: 'GR', + kilograms: 'KG', + ounces: 'OZ', + pounds: 'LB', + miligrams: 'MG' + ) + + def initialize(amount, unit=nil) + super amount, unit || :pounds + end + + end + +end \ No newline at end of file diff --git a/lib/mws/apis/orders.rb b/lib/mws/apis/orders.rb new file mode 100644 index 0000000..7368972 --- /dev/null +++ b/lib/mws/apis/orders.rb @@ -0,0 +1,23 @@ +class Mws::Apis::Orders + + def initialize(connection, overrides={}) + @connection = connection + @param_defaults = { + market: 'ATVPDKIKX0DER' + }.merge overrides + @option_defaults = { + version: '2011-01-01', + list_pattern: '%{key}.%{ext}.%d' + } + end + + def list(params={}) + params[:markets] ||= [ params.delete(:markets) || params.delete(:market) || @param_defaults[:market] ].flatten.compact + options = @option_defaults.merge action: 'ListOrders' + doc = @connection.get "/Orders/#{options[:version]}", params, options + doc.xpath('Orders/Order').map do | node | + 'Someday this will be an Order' + end + end + +end diff --git a/lib/mws/connection.rb b/lib/mws/connection.rb new file mode 100644 index 0000000..a2ec28b --- /dev/null +++ b/lib/mws/connection.rb @@ -0,0 +1,84 @@ +require 'uri' +require 'net/http' +require 'digest/md5' +require 'logging' +require 'nokogiri' + +module Mws + + class Connection + + attr_reader :merchant, :orders, :feeds + + def initialize(overrides) + @log = Logging.logger[self] + @scheme = overrides[:scheme] || 'https' + @host = overrides[:host] || 'mws.amazonservices.com' + @merchant = overrides[:merchant] + raise Mws::Errors::ValidationError, 'A merchant identifier must be specified.' if @merchant.nil? + @access = overrides[:access] + raise Mws::Errors::ValidationError, 'An access key must be specified.' if @access.nil? + @secret = overrides[:secret] + raise Mws::Errors::ValidationError, 'A secret key must be specified.' if @secret.nil? + @orders = Apis::Orders.new self + @feeds = Apis::Feeds::Api.new self + end + + def get(path, params, overrides) + request(:get, path, params, nil, overrides) + end + + def post(path, params, body, overrides) + request(:post, path, params, body, overrides) + end + + private + + def request(method, path, params, body, overrides) + query = Query.new({ + action: overrides[:action], + version: overrides[:version], + merchant: @merchant, + access: @access, + list_pattern: overrides.delete(:list_pattern) + }.merge(params)) + signer = Signer.new method: method, host: @host, path: path, secret: @secret + parse response_for(method, path, signer.sign(query), body), overrides + end + + def response_for(method, path, query, body) + uri = URI("#{@scheme}://#{@host}#{path}?#{query}") + @log.debug "Request URI:\n#{uri}\n" + req = Net::HTTP.const_get(method.to_s.capitalize).new (uri.request_uri) + req['User-Agent'] = 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'] = 'text/xml' + if req.request_body_permitted? and body + @log.debug "Body:\n#{body}" + req.content_type = 'text/xml' + req['Content-MD5'] = Digest::MD5.base64digest(body).strip + @log.debug "Hash:\n#{req['Content-MD5']}\n" + req.body = body + end + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do | http | + http.request req + end + raise Errors::ServerError.new(code: res.code, message: res.msg) if res.body.nil? + @log.debug "Response Body:\n#{res.body}\n" + res.body + end + + def parse(body, overrides) + doc = Nokogiri::XML(body) + doc.remove_namespaces! + doc.xpath('/ErrorResponse/Error').each do | error | + options = {} + error.element_children.each { |node| options[node.name.downcase.to_sym] = node.text } + raise Errors::ServerError.new(options) + end + result = doc.xpath((overrides[:xpath] || '/%{action}Response/%{action}Result') % overrides ).first + result + end + + end + +end \ No newline at end of file diff --git a/lib/mws/enum.rb b/lib/mws/enum.rb new file mode 100644 index 0000000..d49193d --- /dev/null +++ b/lib/mws/enum.rb @@ -0,0 +1,81 @@ +module Mws + + class Enum + + private :initialize + + private_class_method :new + + def initialize(entries) + @reverse = {} + @entries = [] + entries.each do | key, values | + entry = EnumEntry.new(key, values) + @entries << entry + @reverse[key] = entry + values = [ values ] unless values.respond_to? :each + values.each do | value | + @reverse[value] = entry + end + end + end + + def syms + @entries.map { |it| it.sym } + end + + def vals + @entries.map { |it| it.val }.flatten + end + + def for(it) + return it if it.instance_of? EnumEntry + @reverse[it] + end + + def sym(str) + entry = self.for(str) + entry && entry.sym + end + + def val(sym) + entry = self.for(sym) + entry && entry.val + end + + def self.for(h) + it = new(h) + eigenclass = class << it + self + end + h.each do | key, value | + eigenclass.send(:define_method, key.to_s.upcase.to_sym) do + it.for key + end + end + it + end + + def self.sym_reader(target, *attributes) + attributes.each do | attribute | + target.send(:define_method, attribute) do + entry = send(:instance_variable_get, "@#{attribute}") + entry && entry.sym + end + end + end + + end + + class EnumEntry + + attr_reader :sym, :val + + def initialize(sym, val) + @sym = sym + @val = val + end + + end + +end \ No newline at end of file diff --git a/lib/mws/errors.rb b/lib/mws/errors.rb new file mode 100644 index 0000000..8add970 --- /dev/null +++ b/lib/mws/errors.rb @@ -0,0 +1,32 @@ +module Mws::Errors + + class Error < RuntimeError + + end + + class ServerError < Error + + attr_reader :type, :code, :message, :details + + def initialize(options) + @type = options[:type] || 'HTTP' + @code = options[:code] + @message = options[:message] + @details = options[:details] || options[:detail] + if @details.nil? or @details.empty? + @details = 'None' + end + super "Type: #{@type}, Code: #{@code}, Message: #{@message}, Details: #{@details}" + end + + end + + class ClientError < Error + + end + + class ValidationError < ClientError + + end + +end \ No newline at end of file diff --git a/lib/mws/query.rb b/lib/mws/query.rb new file mode 100644 index 0000000..37bf48a --- /dev/null +++ b/lib/mws/query.rb @@ -0,0 +1,45 @@ +require 'time' + +class Mws::Query + + def initialize(overrides) + options = { + signature_method: 'HmacSHA256', + signature_version: '2', + timestamp: Time.now.iso8601 + }.merge overrides + + options[:aws_access_key_id] ||= options.delete :access + options[:seller_id] ||= options.delete(:merchant) || options.delete(:seller) + options[:marketplace_id] ||= options.delete(:markets) || [] + list_pattern = options.delete(:list_pattern) || '%{key}List.%{ext}.%d' + + @params = Hash[options.inject({}) do | params, entry | + key = normalize_key entry.first + if entry.last.respond_to? :each_with_index + entry.last.each_with_index do | value, index | + param_key = list_pattern % { key: key, ext: entry.first.to_s.split('_').last.capitalize, index: index + 1 } + params[param_key] = normalize_val value + end + else + params[key] = normalize_val entry.last + end + params + end.sort] + end + + def to_s + @params.map { |it| it.join '=' }.join '&' + end + + private + + def normalize_key(key) + Mws::Utils.camelize(key).sub /^Aws/, 'AWS' + end + + def normalize_val(value) + Mws::Utils.uri_escape(value.respond_to?(:iso8601) ? value.iso8601 : value.to_s) + end + +end diff --git a/lib/mws/serializer.rb b/lib/mws/serializer.rb new file mode 100644 index 0000000..103a80a --- /dev/null +++ b/lib/mws/serializer.rb @@ -0,0 +1,81 @@ +require 'nokogiri' + +module Mws + + class Serializer + + def self.tree(name, parent, &block) + if parent + parent.send(name, &block) + parent.doc.root.to_xml + else + Nokogiri::XML::Builder.new do | xml | + xml.send(name, &block) + end.doc.root.to_xml + end + end + + def self.leaf(name, parent, value, attributes) + if parent + parent.send(name, value, attributes) + parent.doc.root.to_xml + else + Nokogiri::XML::Builder.new do | xml | + xml.send(name, value, attributes) + end.doc.root.to_xml + end + end + + def initialize(exceptions={}) + @xml_exceptions = exceptions + @hash_exceptions = {} + exceptions.each do | key, value | + @hash_exceptions[value.to_sym] = key + end + end + + def xml_for(name, data, builder, context=nil) + element = @xml_exceptions[name.to_sym] || Utils.camelize(name) + path = path_for name, context + if data.respond_to? :keys + builder.send(element) do | b | + data.each do | key, value | + xml_for(key, value, builder, path) + end + end + elsif data.respond_to? :each + data.each { |value| xml_for(name, value, builder, path) } + elsif data.respond_to? :to_xml + data.to_xml element, builder + else + builder.send element, data + end + end + + def hash_for(node, context) + elements = node.elements() + return node.text unless elements.size > 0 + res = {} + elements.each do | element | + name = @hash_exceptions[element.name.to_sym] || Utils.underscore(element.name).to_sym + path = path_for name, context + content = instance_exec element, path, &method(:hash_for) + if res.include? name + res[name] = [ res[name] ] unless res[name].instance_of? Array + res[name] << content + else + res[name] = content + end + end + res + end + + private + + def path_for(name, context=nil) + [ context, name ].compact.join '.' + end + + end + +end diff --git a/lib/mws/signer.rb b/lib/mws/signer.rb new file mode 100644 index 0000000..2a1396c --- /dev/null +++ b/lib/mws/signer.rb @@ -0,0 +1,20 @@ +class Mws::Signer + + def initialize(options={}) + @verb = (options[:method] || options[:verb] || 'POST').to_s.upcase + @host = (options[:host] || 'mws.amazonservices.com').to_s.downcase + @path = options[:path] || '/' + @secret = options[:secret] + end + + def signature(query, secret=@secret) + digest = OpenSSL::Digest::Digest.new 'sha256' + message = [ @verb, @host, @path, query ].join "\n" + Base64::encode64(OpenSSL::HMAC.digest(digest, secret, message)).chomp + end + + def sign(query, secret=@secret) + "#{query}&Signature=#{Mws::Utils.uri_escape signature(query, secret)}" + end + +end diff --git a/lib/mws/utils.rb b/lib/mws/utils.rb new file mode 100644 index 0000000..f1f792b --- /dev/null +++ b/lib/mws/utils.rb @@ -0,0 +1,50 @@ +# This module contains a collection of generally useful methods that (currently) have no better place to live. They can +# either be referenced directly as module methods or be mixed in. +module Mws::Utils + extend self + + # This method will derive a camelized name from the provided underscored name. + # + # @param [#to_s] name The underscored name to be camelized. + # @param [Boolean] uc_first True if and only if the first letter of the resulting camelized name should be + # capitalized. + # + # @return [String] The camelized name corresponding to the provided underscored name. + def camelize(name, uc_first=true) + return nil if name.nil? + name = name.to_s.strip + return name if name.empty? + parts = name.split '_' + assemble = lambda { |head, tail| head + tail.capitalize } + parts[0] = uc_first ? parts[0].capitalize : parts[0].downcase + parts.inject(&assemble) + end + + def underscore(name) + return nil if name.nil? + name = name.to_s.strip + return name if name.empty? + name.gsub(/::/, '/') + .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2') + .gsub(/([a-z\d])([A-Z])/,'\1_\2') + .tr("-", "_") + .downcase + end + + def uri_escape(value) + value.gsub /([^a-zA-Z0-9_.~-]+)/ do + '%' + $1.unpack('H2' * $1.bytesize).join('%').upcase + end + end + + def alias(to, from, *constants) + constants.each do | name | + constant = from.const_get(name) + to.singleton_class.send(:define_method, name) do | *args, &block | + constant.new *args, &block + end + to.const_set(name, constant) + end + end + +end diff --git a/mws-connect.gemspec b/mws-connect.gemspec new file mode 100644 index 0000000..758693c --- /dev/null +++ b/mws-connect.gemspec @@ -0,0 +1,26 @@ +# -*- encoding: utf-8 -*- +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'mws-connect' + +Gem::Specification.new do |gem| + gem.name = 'mws-connect' + gem.version = Mws::VERSION + gem.authors = ['Sean M. Duncan', 'John E. Bailey'] + gem.license = 'MIT' + gem.email = ['info@devmode.com'] + gem.description = %q{The missing ruby client library for Amazon MWS} + gem.summary = %q{The missing ruby client library for Amazon MWS} + gem.homepage = 'http://github.com/devmode/mws' + + gem.files = `git ls-files`.split($/) + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|specs?|feat(ures?)?)/}) + gem.require_paths = ['lib'] + gem.add_development_dependency 'rspec' + gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'cucumber' + gem.add_development_dependency 'activesupport' + gem.add_dependency 'logging', '~> 1.8.0' + gem.add_dependency 'nokogiri', '~> 1.5.5' +end diff --git a/scripts/catalog-workflow b/scripts/catalog-workflow new file mode 100755 index 0000000..5a556c1 --- /dev/null +++ b/scripts/catalog-workflow @@ -0,0 +1,136 @@ +#!/usr/bin/env ruby + +if ARGV.size < 3 + puts "Usage: 'catalog-workflow merchant access secret'" + exit 1 +end + +require 'mws-connect' +require 'logging' +require 'active_support/core_ext' + +Logging.logger.root.appenders = Logging.appenders.stdout +Logging.logger.root.level = :debug + +mws = Mws.connect( + merchant: ARGV[0], + access: ARGV[1], + secret: ARGV[2] +) + +class Workflow + + def initialize(queue) + @queue = queue + @handlers = [] + end + + def register(*deps, &handler) + @handlers << [ handler, deps ] + end + + def complete(feed) + @handlers.each do | handler | + handler.last.delete feed + end + proceed + end + + def proceed + @handlers.each_with_index do | handler, index | + if handler.last.empty? + @handlers[index] = nil + [ handler.first.call ].compact.flatten.each do | feed | + @queue << feed + end + end + end + @handlers.compact! + end + +end + +in_process_q = [] +terminated_q = [] + +workflow = Workflow.new(in_process_q) + +workflow.register do + product_feed = mws.feeds.products.add( + Mws::Product('2634897') do + tax_code 'A_GEN_TAX' + name "Rocketfish\u2122 6' In-Wall HDMI Cable" + brand "Rocketfish\u2122" + description "This 6' HDMI cable supports signals up to 1080p and most screen refresh rates to ensure stunning image clarity with reduced motion blur in fast-action scenes." + bullet_point 'Compatible with HDMI components' + bullet_point'Connects an HDMI source to an HDTV or projector with an HDMI input' + bullet_point 'Up to 15 Gbps bandwidth' + bullet_point'In-wall rated' + msrp 49.99, :usd + category :ce + details { + cable_or_adapter { + cable_length as_distance 6, :feet + } + } + end + ) + workflow.register product_feed.id do + price_feed = mws.feeds.prices.add( + Mws::PriceListing('2634897', 49.99).on_sale(29.99, Time.now, 3.months.from_now) + ) + image_feed = mws.feeds.images.add( + Mws::ImageListing('2634897', 'http://images.bestbuy.com/BestBuy_US/images/products/2634/2634897_sa.jpg', 'Main'), + Mws::ImageListing('2634897', 'http://images.bestbuy.com/BestBuy_US/images/products/2634/2634897cv1a.jpg', 'PT1') + ) + shipping_feed = mws.feeds.shipping.add( + Mws::Shipping('2634897') { + restricted :alaska_hawaii, :standard, :po_box + adjust 4.99, :usd, :continental_us, :standard + replace 11.99, :usd, :continental_us, :expedited, :street + } + ) + workflow.register price_feed.id, image_feed.id, shipping_feed.id do + inventory_feed = mws.feeds.inventory.add( + Mws::Inventory('2634897', quantity: 10, fulfillment_type: :mfn) + ) + workflow.register inventory_feed.id do + puts 'The workflow is complete!' + end + inventory_feed.id + end + [ price_feed.id, image_feed.id, shipping_feed.id ] + end + product_feed.id +end + +workflow.proceed + +50.times do + puts "In Process: #{in_process_q}" + mws.feeds.list(ids: in_process_q).each do | info | + puts "SubmissionId: #{info.id} Status: #{info.status}" + terminated_q << in_process_q.delete(info.id) if [:cancelled, :done].include? info.status + end unless in_process_q.empty? + + puts "Terminated: #{terminated_q}" + unless terminated_q.empty? + id = terminated_q.shift + result = mws.feeds.get id + puts result.inspect + if result.messages_processed == result.count_for(:success) + workflow.complete result.transaction_id + end + end + puts "| Waiting |" + print '------------------------------------------------------------' + 60.times do |it| + sleep 1 + print "\r" + prg = it + 1 + rem = 60 - prg + prg.times { print '=' } + rem.times { print '-' } + end + print "\n" +end diff --git a/spec/mws/apis/feeds/api_spec.rb b/spec/mws/apis/feeds/api_spec.rb new file mode 100644 index 0000000..404f32a --- /dev/null +++ b/spec/mws/apis/feeds/api_spec.rb @@ -0,0 +1,229 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + class Api + attr_reader :defaults + end + + describe Api do + + let(:connection) do + Mws::Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + end + + let(:api) { Api.new(connection) } + + context '.new' do + + it 'should require connection' do + expect { Api.new(nil) }.to raise_error Mws::Errors::ValidationError, 'A connection is required.' + end + + it 'should default version to 2009-01-01' do + api.defaults[:version].should == '2009-01-01' + end + + it 'should initialize a products feed' do + TargetedApi.as_null_object + TargetedApi.should_receive(:new).with(anything, connection.merchant, :product) + api = Api.new(connection) + api.products.should_not be nil + end + + it 'should initialize an images feed' do + TargetedApi.as_null_object + TargetedApi.should_receive(:new).with(anything, connection.merchant, :image) + api = Api.new(connection) + api.images.should_not be nil + end + + it 'should initialize a prices feed' do + TargetedApi.as_null_object + TargetedApi.should_receive(:new).with(anything, connection.merchant, :price) + api = Api.new(connection) + api.prices.should_not be nil + end + + it 'should initialize a shipping feed' do + TargetedApi.as_null_object + TargetedApi.should_receive(:new).with(anything, connection.merchant, :override) + api = Api.new(connection) + api.shipping.should_not be nil + end + + it 'should initialize an inventory feed' do + TargetedApi.as_null_object + TargetedApi.should_receive(:new).with(anything, connection.merchant, :inventory) + api = Api.new(connection) + api.inventory.should_not be nil + end + + end + + context '#get' do + + it 'should properly delegate to connection' do + connection.should_receive(:get).with('/', { feed_submission_id: 1 }, { + version: '2009-01-01', + action: 'GetFeedSubmissionResult', + xpath: 'AmazonEnvelope/Message' + }).and_return('a_node') + SubmissionResult.should_receive(:from_xml).with('a_node') + api.get(1) + end + + end + + context '#submit' do + + it 'should properly delegate to connection' do + response = double(:response) + response.should_receive(:xpath).with('FeedSubmissionInfo').and_return(['a_result']) + connection.should_receive(:post).with('/', { feed_type: '_POST_INVENTORY_AVAILABILITY_DATA_' }, 'a_body', { + version: '2009-01-01', + action: 'SubmitFeed' + }).and_return(response) + SubmissionInfo.should_receive(:from_xml).with('a_result') + api.submit 'a_body', feed_type: :inventory + end + + end + + context '#list' do + + it 'should handle a single submission id' do + response = double(:response) + response.should_receive(:xpath).with('FeedSubmissionInfo').and_return(['result_one']) + connection.should_receive(:get).with('/', { feed_submission_id: [ 1 ] }, { + version: '2009-01-01', + action: 'GetFeedSubmissionList' + }).and_return(response) + SubmissionInfo.should_receive(:from_xml) { | node | node }.once + api.list(id: 1).should == [ 'result_one' ] + end + + it 'should handle a multiple submission ids' do + response = double(:response) + response.should_receive(:xpath).with('FeedSubmissionInfo').and_return([ 'result_one', 'result_two', 'result_three' ]) + connection.should_receive(:get).with('/', { feed_submission_id: [ 1, 2, 3 ] }, { + version: '2009-01-01', + action: 'GetFeedSubmissionList' + }).and_return(response) + SubmissionInfo.should_receive(:from_xml) { | node | node }.exactly(3).times + api.list(ids: [ 1, 2, 3 ]).should == [ 'result_one', 'result_two', 'result_three' ] + end + + end + + context '#count' do + + it 'should properly delegate to connection' do + count = double(:count) + count.should_receive(:text).and_return('5') + response = double(:response) + response.should_receive(:xpath).with('Count').and_return([ count ]) + connection.should_receive(:get).with('/', {}, { + version: '2009-01-01', + action: 'GetFeedSubmissionCount' + }).and_return(response) + api.count.should == 5 + end + + end + + end + + describe TargetedApi do + + let(:connection) do + Mws::Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + end + + let(:api) { Api.new(connection) } + + context '#add' do + + it 'should properly delegate to #submit' do + api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :update, true).and_return('a_result') + api.products.add('resource_one', 'resource_two').should == 'a_result' + end + + end + + context '#update' do + + it 'should properly delegate to #submit' do + api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :update).and_return('a_result') + api.products.update('resource_one', 'resource_two').should == 'a_result' + end + + end + + context '#patch' do + + it 'should properly delegate to #submit for products' do + api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :partial_update).and_return('a_result') + api.products.patch('resource_one', 'resource_two').should == 'a_result' + end + + it 'should not be supported for feeds other than products' do + expect { api.images.patch('resource_one', 'resource_two') }.to raise_error 'Operation Type not supported.' + end + + end + + context '#delete' do + + it 'should properly delegate to #submit' do + api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :delete).and_return('a_result') + api.products.delete('resource_one', 'resource_two').should == 'a_result' + end + + end + + context '#submit' do + + it 'should properly construct the feed and delegate to feeds' do + resource = double :resource + resource.stub(:to_xml) + resource.stub(:sku).and_return('a_sku') + resource.stub(:operation_type).and_return(:update) + feed_xml = Nokogiri::XML::Builder.new do + AmazonEnvelope('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:noNamespaceSchemaLocation' => 'amznenvelope.xsd') { + Header { + DocumentVersion '1.01' + MerchantIdentifier 'GSWCJ4UBA31UTJ' + } + MessageType 'Product' + PurgeAndReplace false + Message { + MessageID 1 + OperationType 'Update' + } + } + end.doc.to_xml + submission_info = double(:submission_info).as_null_object + api.should_receive(:submit).with(feed_xml, feed_type: Feed::Type.PRODUCT, purge_and_replace: false).and_return(submission_info) + tx = api.products.submit [ resource ], :update + tx.items.size.should == 1 + item = tx.items.first + item.id.should == 1 + item.sku.should == 'a_sku' + item.operation.should == :update + item.qualifier.should be nil + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/distance_spec.rb b/spec/mws/apis/feeds/distance_spec.rb new file mode 100644 index 0000000..0aa9c79 --- /dev/null +++ b/spec/mws/apis/feeds/distance_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Distance do + + context '.new' do + + it 'should default to feet' do + distance = Distance.new 40 + distance.amount.should == 40 + distance.unit.should == :feet + end + + it 'should accept a valid unit override' do + distance = Distance.new 0, :meters + distance.amount.should == 0 + distance.unit.should == :meters + end + + it 'should validate the unit override' do + expect { + Distance.new 40, :acres + }.to raise_error Mws::Errors::ValidationError, "Invalid unit of measure 'acres'" + end + + end + + context '#to_xml' do + + it 'should properly serialize to XML' do + distance = Distance.new 25, :inches + expected = Nokogiri::XML::Builder.new do + Distance 25, unitOfMeasure: 'inches' + end.doc.root.to_xml + distance.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/feed_spec.rb b/spec/mws/apis/feeds/feed_spec.rb new file mode 100644 index 0000000..68c49bd --- /dev/null +++ b/spec/mws/apis/feeds/feed_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Feed do + + let(:merchant) { 'GSWCJ4UBA31UTJ' } + let(:message_type) { :image } + + context '.new' do + + it 'should require a merchant identifier' do + expect { Feed.new(nil, message_type) }.to raise_error Mws::Errors::ValidationError, + 'Merchant identifier is required.' + end + + it 'should require a valid message type' do + expect { Feed.new(merchant, nil) }.to raise_error Mws::Errors::ValidationError, + 'A valid message type is required.' + end + + it 'shoud default purge and replace to false' do + Feed.new(merchant, message_type).purge_and_replace.should be false + end + + it 'should accept overrides to purge and replace' do + Feed.new(merchant, message_type, true).purge_and_replace.should be true + end + + it 'should accept a block to append messages to the feed' do + feed = Feed.new(merchant, message_type) do + message ImageListing.new('1', 'http://foo.com/bar.jpg'), :delete + message ImageListing.new('1', 'http://bar.com/foo.jpg') + end + feed.messages.size.should == 2 + first = feed.messages.first + first.id.should == 1 + first.type.should == :image + first.operation_type.should == :delete + first.resource.should == ImageListing.new('1', 'http://foo.com/bar.jpg') + second = feed.messages.last + second.id.should == 2 + second.type.should == :image + second.operation_type.should == :update + second.resource.should == ImageListing.new('1', 'http://bar.com/foo.jpg') + end + + end + + context '#to_xml' do + + it 'shoud properly serialize to xml' do + expected = Nokogiri::XML::Builder.new { + AmazonEnvelope('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:noNamespaceSchemaLocation' => 'amznenvelope.xsd') { + Header { + DocumentVersion '1.01' + MerchantIdentifier 'GSWCJ4UBA31UTJ' + } + MessageType 'ProductImage' + PurgeAndReplace false + Message { + MessageID 1 + OperationType 'Delete' + ProductImage { + SKU 1 + ImageType 'Main' + ImageLocation 'http://foo.com/bar.jpg' + } + } + Message { + MessageID 2 + OperationType 'Update' + ProductImage { + SKU 1 + ImageType 'Main' + ImageLocation 'http://bar.com/foo.jpg' + } + } + } + }.to_xml + actual = Feed.new(merchant, message_type) do + message ImageListing.new('1', 'http://foo.com/bar.jpg'), :delete + message ImageListing.new('1', 'http://bar.com/foo.jpg') + end.to_xml + actual.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/image_listing_spec.rb b/spec/mws/apis/feeds/image_listing_spec.rb new file mode 100644 index 0000000..5bd2aa8 --- /dev/null +++ b/spec/mws/apis/feeds/image_listing_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe ImageListing do + + let(:sku) { '987612345' } + let(:url) { 'http://domain.com/images/foo.png' } + let(:type) { :alt1 } + let(:listing) { ImageListing.new sku, url, type } + + context '.new' do + + it 'should construct an image listing with url and type' do + listing.sku.should == sku + listing.url.should == url + listing.type.should == type + end + + it 'should default the image listing type to main' do + listing = ImageListing.new sku, url + listing.sku.should == sku + listing.url.should == url + listing.type.should == :main + end + + it 'should require non-nil sku' do + expect { ImageListing.new(nil, url) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a non-empty sku' do + expect { ImageListing.new('', url) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a sku that is not all whitespace' do + expect { ImageListing.new(' ', url) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a non-nil url' do + expect { ImageListing.new(sku, nil) }.to raise_error Mws::Errors::ValidationError, + 'URL must be an unsecured http address.' + end + + it 'should require a valid url' do + expect { ImageListing.new(sku, 'this is not a url') }.to raise_error Mws::Errors::ValidationError, + 'URL must be an unsecured http address.' + end + + it 'should require an http url' do + expect { ImageListing.new(sku, 'ftp://domain.com/images/foo.png') }.to raise_error Mws::Errors::ValidationError, + 'URL must be an unsecured http address.' + end + + it 'should require an unsecure url' do + expect { ImageListing.new(sku, 'https://domain.com/images/foo.png') }.to raise_error Mws::Errors::ValidationError, + 'URL must be an unsecured http address.' + end + + end + + context '#==' do + + it 'should be reflexive' do + (listing == listing).should be true + end + + it 'should be symmetric' do + a = listing + b = ImageListing.new(sku, url, type) + c = ImageListing.new(sku, url) + (a == b).should == (b == a) + (a == c).should == (c == a) + end + + it 'should be transitive' do + a = listing + b = ImageListing.new(sku, url, type) + c = ImageListing.new(sku, url, type) + (a == c).should == (a == b && b == c) + end + + it 'should handle comparison to nil' do + (listing == nil).should be false + end + + end + + context '#to_xml' do + + it 'shoud properly serialize to xml' do + expected = Nokogiri::XML::Builder.new { + ProductImage { + SKU sku + ImageType 'PT1' + ImageLocation url + } + }.doc.root.to_xml + actual = listing.to_xml + actual.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/inventory_spec.rb b/spec/mws/apis/feeds/inventory_spec.rb new file mode 100644 index 0000000..24c39ee --- /dev/null +++ b/spec/mws/apis/feeds/inventory_spec.rb @@ -0,0 +1,135 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Inventory do + + context '.new' do + + it 'should require non-nil sku' do + expect { Inventory.new(nil, quantity: 1) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a non-empty sku' do + expect { Inventory.new('', quantity: 1) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a sku that is not all whitespace' do + expect { Inventory.new(' ', quantity: 1) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should accept a valid value for sku' do + Inventory.new('987612345', quantity: 1).sku.should == '987612345' + end + + it 'should require one of available, quantity or lookup' do + expect { Inventory.new('987612345', {}) }.to raise_error Mws::Errors::ValidationError, + "One and only one of 'available', 'quantity' or 'lookup' must be specified." + end + + it 'should require a boolean value for available' do + expect { Inventory.new('987612345', available: 1) }.to raise_error Mws::Errors::ValidationError, + 'Available must be either true or false.' + end + + it 'should accept a valid value for available' do + Inventory.new('98712345', available: true).available.should be true + end + + it 'should require quantity to be a whole number greater than or equal to zero' do + expect { Inventory.new('987612345', quantity: -1) }.to raise_error Mws::Errors::ValidationError, + 'Quantity must be a whole number greater than or equal to zero.' + end + + it 'should accept a valid value for quantity' do + Inventory.new('987612345', quantity: 1).quantity.should == 1 + end + + it 'should require a boolean value for lookup' do + expect { Inventory.new('987612345', lookup: 'Yes') }.to raise_error Mws::Errors::ValidationError, + 'Lookup must be either true or false.' + end + + it 'should accept a valid value for lookup' do + Inventory.new('987612345', lookup: true).lookup.should be true + end + + it 'should accept only one of available, quantity or lookup' do + expect { + Inventory.new('987612345', available: true, quantity: 1, lookup: 'FulfillmentNetwork') + }.to raise_error Mws::Errors::ValidationError, + "One and only one of 'available', 'quantity' or 'lookup' must be specified." + end + + it 'should accept a valid fulfillment center' do + Inventory.new('987612345', quantity: 1, fulfillment_center: 'foo').fulfillment.center.should == 'foo' + end + + it 'should require fulfillment latency to be a whole number greater than zero' do + expect { + Inventory.new('987612345', quantity: 1, fulfillment_latency: 0) + }.to raise_error Mws::Errors::ValidationError, + 'Fulfillment latency must be a whole number greater than zero.' + end + + it 'should accept a valid fulfillment latency' do + Inventory.new('987612345', quantity: 1, fulfillment_latency: 1).fulfillment.latency.should == 1 + end + + it 'should require fulfillment type to be either AFN or MFN' do + expect { + Inventory.new('987612345', quantity: 1, fulfillment_type: 'foo') + }.to raise_error Mws::Errors::ValidationError, + "Fulfillment type must be either 'AFN' or 'MFN'." + end + + it 'should accept a valid fulfillment type' do + Inventory.new('987612345', quantity: 1, fulfillment_type: :mfn).fulfillment.type.should == :mfn + end + + it 'should require the restock date to be in the future' do + expect { + Inventory.new('987612345', quantity: 0, restock: Time.now) + }.to raise_error Mws::Errors::ValidationError, + 'Restock date must be in the future.' + end + + it 'should accept a valid restock date' do + restock = 4.days.from_now + Inventory.new('987612345', quantity: 0, restock: restock).restock.should == restock + end + + end + + context '#xml_for' do + + it 'should properly serialize to XML' do + restock = 4.days.from_now + inventory = Inventory.new('987612345', + quantity: 5, + fulfillment_center: 'A1B2C3D4E5', + fulfillment_latency: 3, + fulfillment_type: :mfn, + restock: restock + ) + expected = Nokogiri::XML::Builder.new do + Inventory { + SKU '987612345' + FulfillmentCenterID 'A1B2C3D4E5' + Quantity 5 + RestockDate restock.iso8601 + FulfillmentLatency 3 + SwitchFulfillmentTo 'MFN' + } + end.doc.root.to_xml + inventory.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/measurement_spec.rb b/spec/mws/apis/feeds/measurement_spec.rb new file mode 100644 index 0000000..ae788bb --- /dev/null +++ b/spec/mws/apis/feeds/measurement_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + class Temperature < Measurement + + Unit = Mws::Enum.for( + fahrenheit: 'Fahrenheight', + celcius: 'Celcius', + kelvin: 'Kelvin' + ) + + def initialize(amount, unit=:fahrenheit) + super amount, unit + end + + end + + describe Measurement do + + context '.new' do + + it 'should default to fahrenheit' do + temp = Temperature.new 40 + temp.amount.should == 40 + temp.unit.should == :fahrenheit + end + + it 'should accept a valid unit override' do + temp = Temperature.new 0, :kelvin + temp.amount.should == 0 + temp.unit.should == :kelvin + end + + it 'should validate the unit override' do + expect { + Temperature.new 40, :ounces + }.to raise_error Mws::Errors::ValidationError, "Invalid unit of measure 'ounces'" + end + + end + + context '#==' do + + it 'should be reflexive' do + a = Temperature.new 25, :celcius + (a == a).should be true + end + + it 'should be symmetric' do + a = Temperature.new 25, :celcius + b = Temperature.new 25, :celcius + (a == b).should == (b == a) + end + + it 'should be transitive' do + a = Temperature.new 25, :celcius + b = Temperature.new 25, :celcius + c = Temperature.new 25, :celcius + (a == c).should == (a == b && b == c) + end + + it 'should handle comparison to nil' do + a = Temperature.new 25, :celcius + (a == nil).should be false + end + + end + + context '#to_xml' do + + it 'should properly serialize to XML' do + temp = Temperature.new 25, :celcius + expected = Nokogiri::XML::Builder.new do + Temperature 25, unitOfMeasure: 'Celcius' + end.doc.root.to_xml + temp.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/money_spec.rb b/spec/mws/apis/feeds/money_spec.rb new file mode 100644 index 0000000..e3ac2c3 --- /dev/null +++ b/spec/mws/apis/feeds/money_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Money do + + context '.new' do + + it 'should default to usd' do + money = Money.new 40 + money.amount.should == 40 + money.currency.should == :usd + end + + it 'should accept a valid currency override' do + money = Money.new 0, :eur + money.amount.should == 0 + money.currency.should == :eur + end + + it 'should validate the currency override' do + expect { + Money.new 40, :acres + }.to raise_error Mws::Errors::ValidationError, "Invalid currency 'acres'" + end + + end + + context '#to_xml' do + + it 'should properly serialize to XML' do + money = Money.new 25, :cad + expected = Nokogiri::XML::Builder.new do + Price '25.00', currency: 'CAD' + end.doc.root.to_xml + money.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/price_listing_spec.rb b/spec/mws/apis/feeds/price_listing_spec.rb new file mode 100644 index 0000000..645ec89 --- /dev/null +++ b/spec/mws/apis/feeds/price_listing_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe PriceListing do + + context '.new' do + + it 'should be able to construct a price with only sku and base price' do + price = PriceListing.new('987612345', 14.99) + price.sku.should == '987612345' + price.currency.should == :usd + price.base.should == Money.new(14.99, :usd) + price.min.should be nil + price.sale.should be nil + end + + it 'should be able to construct a price with custom currency code' do + price = PriceListing.new('9876123456', 14.99, currency: :eur) + price.currency.should == :eur + price.base.should == Money.new(14.99, :eur) + end + + it 'should be able to construct a price with custom minimum advertised price' do + price = PriceListing.new('987612345', 14.99, min: 11.99) + price.min.should == Money.new(11.99, :usd) + end + + it 'should be able to construct a new price with custom sale price' do + from = 1.day.ago + to = 4.months.from_now + price = PriceListing.new('987612345', 14.99, sale: { + amount: 12.99, + from: from, + to: to + }) + price.sale.should == SalePrice.new(Money.new(12.99, :usd), from, to) + end + + it 'should validate that the base price is less than the minimum advertised price' do + expect { + PriceListing.new('987612345', 9.99, min: 10.00) + }.to raise_error Mws::Errors::ValidationError, "'Base Price' must be greater than 'Minimum Advertised Price'." + end + + it 'should validate that the sale price is less than the minimum advertised price' do + expect { + PriceListing.new('987612345', 14.99, min: 10.00).on_sale(9.99, 1.day.ago, 4.months.from_now) + }.to raise_error Mws::Errors::ValidationError, "'Sale Price' must be greater than 'Minimum Advertised Price'." + end + + end + + context '#on_sale' do + + it 'should provide a nicer syntax for specifying the sale price' do + from = 1.day.ago + to = 4.months.from_now + price = PriceListing.new('987612345', 14.99).on_sale(12.99, from, to) + price.sale.should == SalePrice.new(Money.new(12.99, :usd) , from, to) + end + + end + + context '#to_xml' do + + it 'should properly serialize to XML' do + from = 1.day.ago + to = 4.months.from_now + price = PriceListing.new('987612345', 14.99, currency: :eur, min: 10.99).on_sale(12.99, from, to) + expected = Nokogiri::XML::Builder.new do + Price { + SKU '987612345' + StandardPrice '14.99', currency: 'EUR' + MAP '10.99', currency: 'EUR' + Sale { + StartDate from.iso8601 + EndDate to.iso8601 + SalePrice '12.99', currency: 'EUR' + } + } + end.doc.root.to_xml + price.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/product_spec.rb b/spec/mws/apis/feeds/product_spec.rb new file mode 100644 index 0000000..ed9dfdb --- /dev/null +++ b/spec/mws/apis/feeds/product_spec.rb @@ -0,0 +1,264 @@ +require 'spec_helper' +require 'nokogiri' + +module Mws::Apis::Feeds + + describe Product do + + context '.new' do + + it 'should require a sku' do + expect { Product.new }.to raise_error ArgumentError + + sku = '12343533' + Product.new(sku).sku.should == sku + end + + it 'should support product builder block initialization' do + capture = nil + product = Product.new('123431') do + capture = self + end + capture.should be_an_instance_of Product::ProductBuilder + end + + it 'should support building with upc, tax code, brand, manufacture and name' do + product = Product.new('12324') do + upc '4321' + tax_code 'GEN_TAX_CODE' + brand 'Test Brand' + manufacturer 'Test manufacture' + name 'Test Product' + end + + product.upc.should == '4321' + product.tax_code.should == 'GEN_TAX_CODE' + product.brand.should == 'Test Brand' + product.manufacturer.should == 'Test manufacture' + product.name.should == 'Test Product' + end + + it 'should support building with msrp' do + product = Product.new('12324') do + msrp 10.99, :usd + end + + product.msrp.amount.should == 10.99 + product.msrp.currency.should == :usd + end + + it 'should support building with item dimensions' do + product = Product.new('12324') do + item_dimensions { + length 2, :feet + width 3, :inches + height 1, :meters + weight 4, :pounds + } + end + + product.item_dimensions.length.should == Distance.new(2, :feet) + product.item_dimensions.width.should == Distance.new(3, :inches) + product.item_dimensions.height.should == Distance.new(1, :meters) + product.item_dimensions.weight.should == Weight.new(4, :pounds) + end + + it 'should support building with package dimensions' do + product = Product.new('12324') do + package_dimensions { + length 2, :feet + width 3, :inches + height 1, :meters + weight 4, :pounds + } + end + + product.package_dimensions.length.should == Distance.new(2, :feet) + product.package_dimensions.width.should == Distance.new(3, :inches) + product.package_dimensions.height.should == Distance.new(1, :meters) + product.package_dimensions.weight.should == Weight.new(4, :pounds) + end + + it 'should require valid package and shipping dimensions' do + capture = self + product = Product.new('12324') do + package_dimensions { + capture.expect { length 2, :foots }.to capture.raise_error Mws::Errors::ValidationError + capture.expect { width 2, :decades }.to capture.raise_error Mws::Errors::ValidationError + capture.expect { height 1, :miles }.to capture.raise_error Mws::Errors::ValidationError + capture.expect { weight 1, :stone }.to capture.raise_error Mws::Errors::ValidationError + } + end + end + + it 'should support building with description and bullet points' do + product = Product.new('12343') do + description 'This is a test product description.' + bullet_point 'Bullet Point 1' + bullet_point 'Bullet Point 2' + bullet_point 'Bullet Point 3' + bullet_point 'Bullet Point 4' + end + product.description.should == 'This is a test product description.' + product.bullet_points.length.should == 4 + product.bullet_points[0].should == 'Bullet Point 1' + product.bullet_points[1].should == 'Bullet Point 2' + product.bullet_points[2].should == 'Bullet Point 3' + product.bullet_points[3].should == 'Bullet Point 4' + end + + + it 'should support building with package and shipping weight' do + product = Product.new('12343') do + package_weight 3, :pounds + shipping_weight 4, :ounces + end + + product.package_weight.should == Weight.new(3, :pounds) + product.shipping_weight.should == Weight.new(4, :ounces) + end + + it 'should support building with product details' do + product = Product.new '12343' do + category :ce + details { + value 'some value' + nested { + foo 'bar' + nested { + baz 'bahhh' + } + } + } + end + + product.details.should_not be nil + product.details[:value].should == 'some value' + product.details[:nested][:foo].should == 'bar' + product.details[:nested][:nested][:baz].should == 'bahhh' + end + + it 'should require a category when product details are specified' do + expect { + Product.new '12343' do + details { + value 'some value' + nested { + foo 'bar' + nested { + baz 'bahhh' + } + } + } + end + }.to raise_error Mws::Errors::ValidationError, 'Product must have a category when details are specified.' + end + + end + + context '#to_xml' do + + it 'should create xml for standard attributes' do + + expected = Nokogiri::XML::Builder.new do + Product { + SKU '12343' + StandardProductID { + Type 'UPC' + Value '432154321' + } + ProductTaxCode 'GEN_TAX_CODE' + DescriptionData { + Title 'Test Product' + Brand 'Test Brand' + Description 'Some product' + BulletPoint 'Bullet Point 1' + BulletPoint 'Bullet Point 2' + BulletPoint 'Bullet Point 3' + BulletPoint 'Bullet Point 4' + ItemDimensions { + Length 2, unitOfMeasure: 'feet' + Width 3, unitOfMeasure: 'inches' + Height 1, unitOfMeasure: 'meters' + Weight 4, unitOfMeasure: 'LB' + } + PackageDimensions { + Length 2, unitOfMeasure: 'feet' + Width 3, unitOfMeasure: 'inches' + Height 1, unitOfMeasure: 'meters' + Weight 4, unitOfMeasure: 'LB' + } + PackageWeight 2, unitOfMeasure: 'LB' + ShippingWeight 3, unitOfMeasure: 'MG' + MSRP 19.99, currency: 'USD' + Manufacturer 'Test manufacture' + } + } + end.doc.root.to_xml + + expected.should == Product.new('12343') do + upc '432154321' + tax_code 'GEN_TAX_CODE' + brand 'Test Brand' + name 'Test Product' + description 'Some product' + msrp 19.99, 'USD' + manufacturer 'Test manufacture' + bullet_point 'Bullet Point 1' + bullet_point 'Bullet Point 2' + bullet_point 'Bullet Point 3' + bullet_point 'Bullet Point 4' + item_dimensions { + length 2, :feet + width 3, :inches + height 1, :meters + weight 4, :pounds + } + package_dimensions { + length 2, :feet + width 3, :inches + height 1, :meters + weight 4, :pounds + } + package_weight 2, :pounds + shipping_weight 3, :miligrams + end.to_xml + + end + + it 'should create xml for product details' do + expected = Nokogiri::XML::Builder.new do + Product { + SKU '12343' + DescriptionData {} + ProductData { + CE { + ProductType { + CableOrAdapter { + CableLength 6, unitOfMeasure: 'feet' + CableWeight 6, unitOfMeasure: 'OZ' + CostSavings '6.99', currency: 'USD' + } + } + } + } + } + end.doc.root.to_xml + + expected.should == Product.new('12343') do + category :ce + details { + cable_or_adapter { + cable_length as_distance 6, :feet + cable_weight as_weight 6, :ounces + cost_savings as_money 6.99, :usd + } + } + end.to_xml + end + + end + + end + +end diff --git a/spec/mws/apis/feeds/shipping_spec.rb b/spec/mws/apis/feeds/shipping_spec.rb new file mode 100644 index 0000000..126a5ac --- /dev/null +++ b/spec/mws/apis/feeds/shipping_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Shipping do + + context '.new' do + + it 'should require non-nil sku' do + expect { Shipping.new(nil) }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a non-empty sku' do + expect { Shipping.new('') }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should require a sku that is not all whitespace' do + expect { Shipping.new(' ') }.to raise_error Mws::Errors::ValidationError, + 'SKU is required.' + end + + it 'should accept a valid value for sku' do + Shipping.new('987612345').sku.should == '987612345' + end + + it 'should accept a block to associate shipping option overrides' do + shipping = Shipping.new('987612345') do + replace 4.99, :usd, :continental_us, :standard, :street + end + shipping.sku.should == '987612345' + shipping.options.size.should == 1 + override = shipping.options.first + override.amount.should == Money.new(4.99, :usd) + override.option.region.should == :continental_us + override.option.speed.should == :standard + override.option.variant.should == :street + end + + end + + context '#to_xml' do + shipping = Shipping.new('987612345') do + unrestricted :continental_us, :standard + restricted :continental_us, :expedited + adjust 19.99, :usd, :continental_us, :two_day, :street + replace 29.99, :usd, :continental_us, :one_day, :street + end + expected = Nokogiri::XML::Builder.new do + Override { + SKU '987612345' + ShippingOverride { + ShipOption 'Std Cont US Street Addr' + IsShippingRestricted 'false' + } + ShippingOverride { + ShipOption 'Exp Cont US Street Addr' + IsShippingRestricted 'true' + } + ShippingOverride { + ShipOption 'Second' + Type 'Additive' + ShipAmount '19.99', currency: 'USD' + } + ShippingOverride { + ShipOption 'Next' + Type 'Exclusive' + ShipAmount '29.99', currency: 'USD' + } + } + end.doc.root.to_xml + shipping.to_xml.should == expected + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/submission_info_spec.rb b/spec/mws/apis/feeds/submission_info_spec.rb new file mode 100644 index 0000000..0e6b399 --- /dev/null +++ b/spec/mws/apis/feeds/submission_info_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' +require 'nokogiri' + +module Mws::Apis::Feeds + + describe SubmissionInfo do + + let(:submitted_node) do + Nokogiri::XML::Builder.new do + FeedSubmissionInfo { + FeedSubmissionId 5868304010 + FeedType '_POST_PRODUCT_DATA_' + SubmittedDate '2012-10-16T21:19:08+00:00' + FeedProcessingStatus '_SUBMITTED_' + } + end.doc.root + end + + let(:in_progress_node) do + Nokogiri::XML::Builder.new do + FeedSubmissionInfo { + FeedSubmissionId 5868304010 + FeedType '_POST_PRODUCT_DATA_' + SubmittedDate '2012-10-16T21:19:08+00:00' + FeedProcessingStatus '_IN_PROGRESS_' + StartedProcessingDate '2012-10-16T21:21:35+00:00' + } + end.doc.root + end + + let(:done_node) do + Nokogiri::XML::Builder.new do + FeedSubmissionInfo { + FeedSubmissionId 5868304010 + FeedType '_POST_PRODUCT_DATA_' + SubmittedDate '2012-10-16T21:19:08+00:00' + FeedProcessingStatus '_DONE_' + StartedProcessingDate '2012-10-16T21:21:35+00:00' + CompletedProcessingDate '2012-10-16T21:23:40+00:00' + } + end.doc.root + end + + it 'should not allow instance creation via new' do + expect { SubmissionInfo.new }.to raise_error NoMethodError + end + + context '.from_xml' do + + it 'should be able to create an info object in a submitted state' do + info = SubmissionInfo.from_xml submitted_node + info.id.should == "5868304010" + info.status.should == SubmissionInfo::Status.SUBMITTED.sym + info.type.should == Feed::Type.PRODUCT.sym + info.submitted.should == Time.parse('2012-10-16T21:19:08+00:00') + info.started.should be_nil + info.completed.should be_nil + end + + it 'should be able to create an info object in and in progress state' do + info = SubmissionInfo.from_xml in_progress_node + info.id.should == "5868304010" + info.status.should == SubmissionInfo::Status.IN_PROGRESS.sym + info.type.should == Feed::Type.PRODUCT.sym + info.submitted.should == Time.parse('2012-10-16T21:19:08+00:00') + info.started.should == Time.parse('2012-10-16T21:21:35+00:00') + info.completed.should be_nil + end + + it 'should be able to create an info object in a done state' do + info = SubmissionInfo.from_xml done_node + info.id.should == "5868304010" + info.status.should == SubmissionInfo::Status.DONE.sym + info.type.should == Feed::Type.PRODUCT.sym + info.submitted.should == Time.parse('2012-10-16T21:19:08+00:00') + info.started.should == Time.parse('2012-10-16T21:21:35+00:00') + info.completed.should == Time.parse('2012-10-16T21:23:40+00:00') + end + + end + + context '#==' do + + it 'should be reflexive' do + a = SubmissionInfo.from_xml submitted_node + (a == a).should be true + end + + it 'should be symmetric' do + a = SubmissionInfo.from_xml submitted_node + b = SubmissionInfo.from_xml submitted_node + (a == b).should == (b == a) + end + + it 'should be transitive' do + a = SubmissionInfo.from_xml submitted_node + b = SubmissionInfo.from_xml submitted_node + c = SubmissionInfo.from_xml submitted_node + (a == c).should == (a == b && b == c) + end + + it 'should handle comparison to nil' do + a = SubmissionInfo.from_xml submitted_node + (a == nil).should be false + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/submission_result_spec.rb b/spec/mws/apis/feeds/submission_result_spec.rb new file mode 100644 index 0000000..4cba42b --- /dev/null +++ b/spec/mws/apis/feeds/submission_result_spec.rb @@ -0,0 +1,157 @@ +require 'spec_helper' +require 'nokogiri' + +module Mws::Apis::Feeds + + class SubmissionResult + attr_reader :responses + end + + describe SubmissionResult do + let(:success_node) do + Nokogiri::XML::Builder.new do + Message { + MessageID 1 + ProcessingReport { + DocumentTransactionID 5868304010 + StatusCode 'Complete' + ProcessingSummary { + MessagesProcessed 1 + MessagesSuccessful 1 + MessagesWithError 0 + MessagesWithWarning 0 + } + } + } + end.doc.root + end + let(:error_node) do + Nokogiri::XML::Builder.new do + Message { + MessageID 1 + ProcessingReport { + DocumentTransactionID 5868304010 + StatusCode 'Complete' + ProcessingSummary { + MessagesProcessed 2 + MessagesSuccessful 0 + MessagesWithError 2 + MessagesWithWarning 1 + } + Result { + MessageID 1 + ResultCode 'Error' + ResultMessageCode 8560 + ResultDescription 'Result description 1' + AdditionalInfo { + SKU '3455449' + } + } + Result { + MessageID 2 + ResultCode 'Error' + ResultMessageCode 5000 + ResultDescription "Result description 2" + AdditionalInfo { + SKU '8744969' + } + } + Result { + MessageID 3 + ResultCode 'Warning' + ResultMessageCode 5001 + ResultDescription "Result description 3" + AdditionalInfo { + SKU '7844970' + } + } + } + } + end.doc.root + end + + it 'should not allow instance creation via new' do + expect { SubmissionResult.new }.to raise_error NoMethodError + end + + describe '.from_xml' do + + it 'should be able to be constructed from valid success xml' do + result = SubmissionResult.from_xml success_node + result.transaction_id.should == '5868304010' + result.status.should == SubmissionResult::Status.COMPLETE.sym + result.messages_processed.should == 1 + result.count_for(:success).should == 1 + result.count_for(:error).should == 0 + result.count_for(:warning).should == 0 + result.responses.should be_empty + end + + it 'should be able to be constructed from valid error xml' do + result = SubmissionResult.from_xml error_node + result.transaction_id.should == '5868304010' + result.status.should == SubmissionResult::Status.COMPLETE.sym + result.messages_processed.should == 2 + result.count_for(:success).should == 0 + result.count_for(:error).should == 2 + result.count_for(:warning).should == 1 + result.responses.size.should == 3 + + response = result.response_for 1 + response.type.should == SubmissionResult::Response::Type.ERROR.sym + response.code.should == 8560 + response.description == 'Result description 1' + response.additional_info.should == { + sku: '3455449' + } + + + response = result.response_for 2 + response.type.should == SubmissionResult::Response::Type.ERROR.sym + response.code.should == 5000 + response.description == 'Result description 2' + response.additional_info.should == { + sku: '8744969' + } + + response = result.response_for 3 + response.type.should == SubmissionResult::Response::Type.WARNING.sym + response.code.should == 5001 + response.description == 'Result description 3' + response.additional_info.should == { + sku: '7844970' + } + end + + end + + context '#==' do + + it 'should be reflexive' do + a = SubmissionResult.from_xml success_node + (a == a).should be true + end + + it 'should be symmetric' do + a = SubmissionResult.from_xml success_node + b = SubmissionResult.from_xml success_node + (a == b).should == (b == a) + end + + it 'should be transitive' do + a = SubmissionResult.from_xml success_node + b = SubmissionResult.from_xml success_node + c = SubmissionResult.from_xml success_node + (a == c).should == (a == b && b == c) + end + + it 'should handle comparison to nil' do + a = SubmissionResult.from_xml success_node + (a == nil).should be false + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/transaction_spec.rb b/spec/mws/apis/feeds/transaction_spec.rb new file mode 100644 index 0000000..bc802a1 --- /dev/null +++ b/spec/mws/apis/feeds/transaction_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' +require 'nokogiri' + +module Mws::Apis::Feeds + + describe Transaction do + + let(:submission_info) do + SubmissionInfo.from_xml( + Nokogiri::XML::Builder.new do + FeedSubmissionInfo { + FeedSubmissionId 5868304010 + FeedType '_POST_PRODUCT_DATA_' + SubmittedDate '2012-10-16T21:19:08+00:00' + FeedProcessingStatus '_SUBMITTED_' + } + end.doc.root + ) + end + + describe '.new' do + + it 'should be able to create a transaction with no items' do + transaction = Transaction.new submission_info + transaction.id.should == "5868304010" + transaction.status.should == SubmissionInfo::Status.SUBMITTED.sym + transaction.type.should == Feed::Type.PRODUCT.sym + transaction.submitted.should == Time.parse('2012-10-16T21:19:08+00:00') + transaction.items.should be_empty + end + + it 'should be able to create a transaction with items' do + transaction = Transaction.new submission_info do + item 1, '12345678', :update + item 2, '87654321', :update, :main + item 3, '87654321', :delete, :other + end + + transaction.items.length.should == 3 + + item = transaction.items[0] + item.id.should == 1 + item.sku.should == '12345678' + item.operation.should == :update + item.qualifier.should be_nil + + item = transaction.items[1] + item.id.should == 2 + item.sku.should == '87654321' + item.operation.should == :update + item.qualifier.should == :main + + item = transaction.items[2] + item.id.should == 3 + item.sku.should == '87654321' + item.operation.should == :delete + item.qualifier.should == :other + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/feeds/weight_spec.rb b/spec/mws/apis/feeds/weight_spec.rb new file mode 100644 index 0000000..e410e3b --- /dev/null +++ b/spec/mws/apis/feeds/weight_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module Mws::Apis::Feeds + + describe Weight do + + context '.new' do + + it 'should default to pounds' do + weight = Weight.new 40 + weight.amount.should == 40 + weight.unit.should == :pounds + end + + it 'should accept a valid unit override' do + weight = Weight.new 0, :ounces + weight.amount.should == 0 + weight.unit.should == :ounces + end + + it 'should validate the unit override' do + expect { + Weight.new 50, :cent + }.to raise_error Mws::Errors::ValidationError, "Invalid unit of measure 'cent'" + end + + end + + context '#to_xml' do + + it 'should properly serialize to XML' do + weight = Weight.new 25, :grams + expected = Nokogiri::XML::Builder.new do + Weight 25, unitOfMeasure: 'GR' + end.doc.root.to_xml + weight.to_xml.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/orders_spec.rb b/spec/mws/apis/orders_spec.rb new file mode 100644 index 0000000..8a82979 --- /dev/null +++ b/spec/mws/apis/orders_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +module Mws::Apis + + describe Orders do + + end + +end \ No newline at end of file diff --git a/spec/mws/connection_spec.rb b/spec/mws/connection_spec.rb new file mode 100644 index 0000000..26e80e3 --- /dev/null +++ b/spec/mws/connection_spec.rb @@ -0,0 +1,331 @@ +require 'spec_helper' +require 'nokogiri' + +module Mws + + class Connection + attr_reader :scheme, :host, :merchant, :access, :secret + public :request, :response_for, :parse + end + + describe Connection do + + let(:defaults) { + { + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + } + } + + let(:connection) { + Mws.connect(defaults) + } + + context '.new' do + + it 'should default scheme to https' do + connection.scheme.should == 'https' + end + + it 'should accept a custom scheme' do + Connection.new(defaults.merge(scheme: 'http')).scheme.should == 'http' + end + + it 'should default host to mws.amazonservices.com' do + connection.host.should == 'mws.amazonservices.com' + end + + it 'should accept a custom host' do + Connection.new(defaults.merge(host: 'mws.amazonservices.uk')).host.should == 'mws.amazonservices.uk' + end + + it 'should require a merchant identifier' do + expect { + Connection.new( + access: defaults[:access], + secret: defaults[:secret] + ) + }.to raise_error Mws::Errors::ValidationError, 'A merchant identifier must be specified.' + end + + it 'should accept a merchant identifier' do + connection.merchant.should == 'GSWCJ4UBA31UTJ' + end + + it 'should require an access key' do + expect { + Connection.new( + merchant: defaults[:merchant], + secret: defaults[:secret] + ) + }.to raise_error Mws::Errors::ValidationError, 'An access key must be specified.' + end + + it 'should accept an access key' do + connection.access.should == 'AYQAKIAJSCWMLYXAQ6K3' + end + + it 'should require a secret key' do + expect { + Connection.new( + merchant: defaults[:merchant], + access: defaults[:access] + ) + }.to raise_error Mws::Errors::ValidationError, 'A secret key must be specified.' + end + + it 'should accept a secret key' do + connection.secret.should == 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + end + + end + + context '#get' do + + it 'should appropriately delegate to #request' do + connection.should_receive(:request).with(:get, '/foo', { market: 'ATVPDKIKX0DER' }, nil, { version: 1 }) + connection.get('/foo', { market: 'ATVPDKIKX0DER' }, { version: 1 }) + end + + end + + context '#post' do + + it 'should appropriately delegate to #request' do + connection.should_receive(:request).with(:post, '/foo', { market: 'ATVPDKIKX0DER' }, 'test_body', { version: 1 }) + connection.post('/foo', { market: 'ATVPDKIKX0DER' }, 'test_body', { version: 1 }) + end + + end + + context '#request' do + + it 'should construct a query, signer and make the request' do + Query.should_receive(:new).with( + action: nil, + version: nil, + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + list_pattern: nil + ).and_return('the_query') + signer = double('signer') + Signer.should_receive(:new).with( + method: :get, + host: 'mws.amazonservices.com', + path: '/foo', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ).and_return(signer) + signer.should_receive(:sign).with('the_query').and_return('the_signed_query') + connection.should_receive(:response_for).with(:get, '/foo', 'the_signed_query', nil).and_return('the_response') + connection.should_receive(:parse).with('the_response', {}) + connection.request(:get, '/foo', {}, nil, {}) + end + + it 'should merge additional request parameters into the query' do + connection = Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + Query.should_receive(:new).with( + action: nil, + version: nil, + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + list_pattern: nil, + foo: 'bar', + baz: 'quk' + ).and_return('the_query') + signer = double('signer') + Signer.should_receive(:new).with( + method: :get, + host: 'mws.amazonservices.com', + path: '/foo', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ).and_return(signer) + signer.should_receive(:sign).with('the_query').and_return('the_signed_query') + connection.should_receive(:response_for).with(:get, '/foo', 'the_signed_query', nil).and_return('the_response') + connection.should_receive(:parse).with('the_response', {}) + connection.request(:get, '/foo', { foo: 'bar', baz: 'quk' }, nil, {}) + end + + it 'should accept overrides to action, version and list_pattern' do + Query.should_receive(:new).with( + action: 'SubmitFeed', + version: '2009-01-01', + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + list_pattern: 'a_list_pattern' + ).and_return('the_query') + signer = double('signer') + Signer.should_receive(:new).with( + method: :get, + host: 'mws.amazonservices.com', + path: '/foo', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ).and_return(signer) + signer.should_receive(:sign).with('the_query').and_return('the_signed_query') + connection.should_receive(:response_for).with(:get, '/foo', 'the_signed_query', nil).and_return('the_response') + connection.should_receive(:parse).with('the_response', { action: 'SubmitFeed', version: '2009-01-01' }) + connection.request(:get, '/foo', {}, nil, { action: 'SubmitFeed', version: '2009-01-01', list_pattern: 'a_list_pattern' }) + end + + end + + context '#parse' do + + it 'should parse error messages correctly' do + body = <<-XML + + + + Sender + InvalidParameterValue + CreatedAfter or LastUpdatedAfter must be specified + + fb03503e-97e3-4ed1-88e9-d93f4d2111c1 + + XML + expect { connection.parse(body, {}) }.to raise_error do | error | + error.should be_a Errors::ServerError + error.type.should == 'Sender' + error.code.should == 'InvalidParameterValue' + error.message.should == 'CreatedAfter or LastUpdatedAfter must be specified' + error.details.should == 'None' + end + end + + it 'should parse result based on custom action' do + body = <<-XML + + + + + 2012-11-19T20:54:33Z + + + 931137cb-add7-4232-ac08-b701435c8447 + + + XML + result = connection.parse(body, action: 'ListOrders').name.should == 'ListOrdersResult' + end + + it 'shoudl parse result base on custom xpath' do + body = <<-XML + + + + + 2012-11-19T20:54:33Z + + + 931137cb-add7-4232-ac08-b701435c8447 + + + XML + result = connection.parse(body, xpath: '/ListOrdersResponse/ListOrdersResult').name.should == 'ListOrdersResult' + end + + end + + context '#response_for' do + + it 'should properly handle a secure get request' do + response = double(:response) + response.should_receive(:body).exactly(3).times.and_return('response_body') + http = double(:http) + http.should_receive(:request) do | req | + req.should be_a Net::HTTP::Get + req.method.should == 'GET' + req.path.should == '/?foo=bar' + req['User-Agent'].should == 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'].should == 'text/xml' + response + end + Net::HTTP.should_receive(:start).with('mws.amazonservices.com', 443, use_ssl: true).and_yield(http) + connection.response_for(:get, '/', 'foo=bar', nil).should == 'response_body' + end + + it 'should properly handle an insecure get request' do + connection = Connection.new(defaults.merge(scheme: 'http')) + response = double(:response) + response.should_receive(:body).exactly(3).times.and_return('response_body') + http = double(:http) + http.should_receive(:request) do | req | + req.should be_a Net::HTTP::Get + req.method.should == 'GET' + req.path.should == '/?foo=bar' + req['User-Agent'].should == 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'].should == 'text/xml' + response + end + Net::HTTP.should_receive(:start).with('mws.amazonservices.com', 80, use_ssl: false).and_yield(http) + connection.response_for(:get, '/', 'foo=bar', nil).should == 'response_body' + end + + it 'should properly handle requests with transport level errors' do + response = double(:response) + response.should_receive(:body).and_return(nil) + response.should_receive(:code).and_return(500) + response.should_receive(:msg).and_return('Internal Server Error') + http = double(:http) + http.should_receive(:request) do | req | + req.should be_a Net::HTTP::Get + req.method.should == 'GET' + req.path.should == '/?foo=bar' + req['User-Agent'].should == 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'].should == 'text/xml' + response + end + Net::HTTP.should_receive(:start).with('mws.amazonservices.com', 443, use_ssl: true).and_yield(http) + expect { connection.response_for(:get, '/', 'foo=bar', nil) }.to raise_error do | error | + error.should be_a Errors::ServerError + error.type.should == 'Server' + error.code.should == 500 + error.message.should == 'Internal Server Error' + error.detail.should == 'None' + end + end + + it 'should properly handle a post without a body' do + response = double(:response) + response.should_receive(:body).exactly(3).times.and_return('response_body') + http = double(:http) + http.should_receive(:request) do | req | + req.should be_a Net::HTTP::Post + req.method.should == 'POST' + req.path.should == '/?foo=bar' + req['User-Agent'].should == 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'].should == 'text/xml' + response + end + Net::HTTP.should_receive(:start).with('mws.amazonservices.com', 443, use_ssl: true).and_yield(http) + connection.response_for(:post, '/', 'foo=bar', nil).should == 'response_body' + end + + it 'should properly handle a post with a body' do + response = double(:response) + response.should_receive(:body).exactly(3).times.and_return('response_body') + http = double(:http) + http.should_receive(:request) do | req | + req.should be_a Net::HTTP::Post + req.method.should == 'POST' + req.path.should == '/?foo=bar' + req.content_type.should == 'text/xml' + req.body.should == 'request_body' + req['Content-MD5'] = Digest::MD5.base64digest('request_body').strip + req['User-Agent'].should == 'MWS Connect/0.0.1 (Language=Ruby)' + req['Accept-Encoding'].should == 'text/xml' + response + end + Net::HTTP.should_receive(:start).with('mws.amazonservices.com', 443, use_ssl: true).and_yield(http) + connection.response_for(:post, '/', 'foo=bar', 'request_body').should == 'response_body' + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/enum_spec.rb b/spec/mws/enum_spec.rb new file mode 100644 index 0000000..7537f20 --- /dev/null +++ b/spec/mws/enum_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' + +module Mws + + describe Enum do + + let (:options) do + { + pending: 'Pending', + unshipped: [ 'Unshipped', 'PartiallyShipped' ], + shipped: 'Shipped', + invoice_unconfirmed: 'InvoiceUnconfirmed', + cancelled: 'Cancelled', + unfulfillable: 'Unfulfillable' + } + end + + before(:all) do + OrderStatus = Enum.for options + end + + it 'should not allow instance creation via new' do + expect { Enum.new }.to raise_error NoMethodError + end + + context '.for' do + + it 'should construct a pseudo-constant accessor for each provided symbol' do + options.each do | key, value | + OrderStatus.send(key.to_s.upcase.to_sym).should_not be nil + end + end + + it 'should not share pseudo-constants between enumeration instances' do + EnumOne = Enum.for( foo: 'Foo', bar: 'Bar', baz: 'Baz' ) + EnumTwo = Enum.for( bar: 'BAR', baz: 'BAZ', quk: 'QUK' ) + expect { EnumOne.QUK }.to raise_error NoMethodError + expect { EnumTwo.FOO }.to raise_error NoMethodError + EnumOne.BAR.should_not == EnumTwo.BAR + end + + end + + context '.sym_reader' do + + class HasEnumAttrs + + EnumOne = Enum.for( foo: 'Foo', bar: 'Bar', baz: 'Baz' ) + + EnumTwo = Enum.for( bar: 'BAR', baz: 'BAZ', quk: 'QUK' ) + + Enum.sym_reader self, :one, :two + + def initialize(one, two) + @one = EnumOne.for(one) + @two = EnumTwo.for(two) + end + + end + + it 'should synthesize a attr_reader that exposes an enum entry as a symbol' do + it = HasEnumAttrs.new(:foo, :quk) + it.send(:instance_variable_get, '@one').should == HasEnumAttrs::EnumOne.FOO + it.one.should == :foo + it.send(:instance_variable_get, '@two').should == HasEnumAttrs::EnumTwo.QUK + it.two.should == :quk + end + + it 'should synthesize attr_readers that are null safe' do + it = HasEnumAttrs.new(:quk, :foo) + it.one.should be nil + it.two.should be nil + end + + end + + context '#for' do + + it 'should be able to find an enum entry from a symbol' do + OrderStatus.for(:pending).should == OrderStatus.PENDING + end + + it 'should be able to find an enum entry from a string' do + OrderStatus.for('Pending').should == OrderStatus.PENDING + end + + it 'should be able to find an enum entry from an enum entry' do + OrderStatus.for(OrderStatus.PENDING).should == OrderStatus.PENDING + end + + end + + context '#sym' do + + it 'should return nil for nil value' do + OrderStatus.sym(nil).should be nil + end + + it 'should return nil for an unknown value' do + OrderStatus.sym('UnknownValue').should be nil + end + + it 'should provide the symbol for a given value' do + OrderStatus.sym('Pending').should == :pending + OrderStatus.sym('Unshipped').should == :unshipped + OrderStatus.sym('PartiallyShipped').should == :unshipped + OrderStatus.sym('Shipped').should == :shipped + OrderStatus.sym('Cancelled').should == :cancelled + OrderStatus.sym('Unfulfillable').should == :unfulfillable + end + + end + + context '#val' do + + it 'should return nil for nil symbol' do + OrderStatus.val(nil).should be nil + end + + it 'should return nil for an unknown sumbol' do + OrderStatus.val(:unknown).should be nil + end + + it 'should provide the value for a given symbol' do + OrderStatus.val(:pending).should == 'Pending' + OrderStatus.val(:unshipped).should == [ 'Unshipped', 'PartiallyShipped' ] + OrderStatus.val(:shipped).should == 'Shipped' + OrderStatus.val(:cancelled).should == 'Cancelled' + OrderStatus.val(:unfulfillable).should == 'Unfulfillable' + end + + end + + context '#syms' do + + it 'should provide the set of symbols' do + OrderStatus.syms.should == options.keys + end + + end + + context '#vals' do + + it 'should provide the list of values' do + OrderStatus.vals.should == options.values.flatten + end + + end + + it 'should be able to provide a symbol for an entry' do + OrderStatus.PENDING.sym.should == :pending + end + + it 'should be able to provide a value for an enum entry' do + OrderStatus.PENDING.val.should == 'Pending' + end + + it 'should be able to handle multivalued enum entries' do + OrderStatus.for(:unshipped).should == OrderStatus.UNSHIPPED + OrderStatus.for('Unshipped').should == OrderStatus.UNSHIPPED + OrderStatus.for('PartiallyShipped').should == OrderStatus.UNSHIPPED + end + + end + +end \ No newline at end of file diff --git a/spec/mws/query_spec.rb b/spec/mws/query_spec.rb new file mode 100644 index 0000000..290327e --- /dev/null +++ b/spec/mws/query_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +module Mws + + class Query + attr_reader :params + end + + describe Query do + + let(:defaults) do + { + access: 'Q6K3SCWMLYAKIAJXAAYQ', + merchant: 'J4UBGSWCA31UTJ', + markets: [ 'ATVPDKIKX0DER', 'KIKX0DERATVPD' ], + last_updated_after: 4.hours.ago + } + end + + let(:query) { Query.new defaults } + + it 'should default SignatureMethod to HmacSHA256' do + query.params['SignatureMethod'].should == 'HmacSHA256' + end + + it 'should default SignatureVersion to 2' do + query.params['SignatureVersion'].should == '2' + end + + it 'should default Timestamp to now in iso8601 format' do + time = URI.decode(query.params['Timestamp']).should == Time.now.iso8601 + end + + it 'should accept overrides to SignatureMethod' do + Query.new(defaults.merge(signature_method: 'HmacSHA1')).params['SignatureMethod'].should == 'HmacSHA1' + end + + it 'should accept overrides to SignatureVersion' do + Query.new(defaults.merge(signature_version: 3)).params['SignatureVersion'].should == '3' + end + + it 'should accept overrides to Timestamp' do + time = 4.hours.ago + query = Query.new(defaults.merge(timestamp: time)) + URI.decode(query.params['Timestamp']).should == time.iso8601 + end + + it 'should translate access to AWSAccessKeyId' do + access_key = 'Q6K3SCWMLYAKIAJXAAYQ' + Query.new(defaults.merge(access: access_key)).params['AWSAccessKeyId'].should == access_key + end + + it 'should translate merchant or seller to seller_id' do + merchant = 'J4UBGSWCA31UTJ' + queries = [ Query.new(defaults.merge(merchant: merchant)), Query.new(defaults.merge(seller: merchant)) ] + queries.each do | query | + query.params['SellerId'].should == merchant + end + end + + it 'should gracefully handle empty markets list' do + Query.new(defaults.merge(markets: [])).params['MarketplaceIdList.Id.1'].should be nil + end + + it 'should translate single market to MarketplaceIdList.Id.1' do + market = 'ATVPDKIKX0DER' + Query.new(defaults.merge(markets: [ market ])).params['MarketplaceIdList.Id.1'].should == market + end + + it 'should translate multiple markets to MarketplaceIdList.Id.*' do + markets = [ 'ATVPDKIKX0DER', 'KIKX0DERATVPD' ] + query = Query.new defaults.merge(markets: markets) + markets.each_with_index do | market, index | + query.params["MarketplaceIdList.Id.#{index + 1}"].should == market + end + end + + it 'should allow for overriding the list representation strategy via list_pattern' do + markets = [ 'ATVPDKIKX0DER', 'KIKX0DERATVPD' ] + list_pattern = '%{key}[%d]' + query = Query.new defaults.merge(markets: markets, list_pattern: list_pattern) + markets.each_with_index do | market, index | + query.params["MarketplaceId[#{index + 1}]"].should == market + end + end + + it 'should sort query parameters lexicographically' do + query.params.inject('') do | prev, entry | + entry.first.should be > prev + entry.first + end + end + + it 'should convert to a compliant query string' do + query_string = query.to_s + query_string.split('&').each do | entry | + key, value = entry.split '=' + query.params[key].should == value + end + end + + end + +end \ No newline at end of file diff --git a/spec/mws/serializer_spec.rb b/spec/mws/serializer_spec.rb new file mode 100644 index 0000000..2f90b6f --- /dev/null +++ b/spec/mws/serializer_spec.rb @@ -0,0 +1,187 @@ +require 'spec_helper' + +module Mws + + describe Serializer do + + let(:from) { 1.day.from_now } + let(:to) { 3.months.from_now } + let(:regular_price) { 21.99 } + let(:sale_price) { 14.99 } + + context '.tree' do + + it 'should properly serialize without a parent' do + expected = Nokogiri::XML::Builder.new { + Sale { + StartDate from.iso8601 + EndDate to.iso8601 + SalePrice sale_price, currency: 'USD' + } + }.doc.root.to_xml + actual = Serializer.tree 'Sale', nil do | xml | + xml.StartDate from.iso8601 + xml.EndDate to.iso8601 + xml.SalePrice sale_price, currency: 'USD' + end + actual.should == expected + end + + it 'should properly serialize with a parent' do + sku = '7890123456' + expected = Nokogiri::XML::Builder.new { + Price { + SKU sku + StandardPrice regular_price, currency: 'USD' + Sale { + StartDate from.iso8601 + EndDate to.iso8601 + SalePrice sale_price, currency: 'USD' + } + } + }.to_xml + actual = Nokogiri::XML::Builder.new { + Price { + SKU sku + StandardPrice regular_price, currency: 'USD' + actual = Serializer.tree 'Sale', self do | xml | + xml.StartDate from.iso8601 + xml.EndDate to.iso8601 + xml.SalePrice sale_price, currency: 'USD' + end + } + }.to_xml + actual.should == expected + end + + end + + context '.leaf' do + + it 'should properly serialize without a parent' do + expected = Nokogiri::XML::Builder.new { + SalePrice sale_price, currency: 'USD' + }.doc.root.to_xml + actual = Serializer.leaf 'SalePrice', nil, sale_price, currency: 'USD' + actual.should == expected + end + + it 'should properly serialize with a parent' do + expected = Nokogiri::XML::Builder.new { + Sale { + StartDate from.iso8601 + EndDate to.iso8601 + SalePrice sale_price, currency: 'USD' + } + }.to_xml + actual = Nokogiri::XML::Builder.new { + Sale { + StartDate from.iso8601 + EndDate to.iso8601 + Serializer.leaf 'SalePrice', self, sale_price, currency: 'USD' + } + }.to_xml + actual.should == expected + end + + end + + context '#xml_for' do + + let(:data) do + { + foo: 'Bar', + foo_bar: { + baz_quk: 'FooBarBazQuk' + }, + baz: [ + 'Foo', + 'Bar', + 'Baz', + 'Quk' + ], + price: Mws::Money(regular_price, :usd) + } + end + + it 'should work with no exceptions' do + expected = Nokogiri::XML::Builder.new { + Data { + Foo 'Bar' + FooBar { + BazQuk 'FooBarBazQuk' + } + Baz 'Foo' + Baz 'Bar' + Baz 'Baz' + Baz 'Quk' + Price regular_price, currency: 'USD' + } + }.to_xml + actual = Nokogiri::XML::Builder.new + Serializer.new.xml_for('Data', data, actual) + actual.to_xml.should == expected + end + + it 'should work with exceptions' do + expected = Nokogiri::XML::Builder.new { + Data { + FOO 'Bar' + FooBar { + BazQuk 'FooBarBazQuk' + } + BaZ 'Foo' + BaZ 'Bar' + BaZ 'Baz' + BaZ 'Quk' + Price regular_price, currency: 'USD' + } + }.to_xml + actual = Nokogiri::XML::Builder.new + Serializer.new(foo: 'FOO', baz: 'BaZ').xml_for('Data', data, actual) + actual.to_xml.should == expected + end + + end + + context '#hash_for' do + + let(:xml) do + Nokogiri::XML::Builder.new { + Data { + FOO 'Bar' + FooBar { + BazQuk 'FooBarBazQuk' + } + BaZ 'Foo' + BaZ 'Bar' + BaZ 'Baz' + BaZ 'Quk' + Price regular_price, currency: 'USD' + } + }.doc.root + end + + it 'should work with exceptions' do + expected = { + foo: 'Bar', + foo_bar: { + baz_quk: 'FooBarBazQuk' + }, + baz: [ + 'Foo', + 'Bar', + 'Baz', + 'Quk' + ], + price: regular_price.to_s + } + actual = Serializer.new(foo: 'FOO', baz: 'BaZ').hash_for(xml, nil) + actual.should == expected + end + + end + + end + +end \ No newline at end of file diff --git a/spec/mws/signer_spec.rb b/spec/mws/signer_spec.rb new file mode 100644 index 0000000..640348a --- /dev/null +++ b/spec/mws/signer_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +module Mws + + class Signer + attr_reader :verb, :host, :path, :secret + end + + describe Signer do + + let(:query) { 'AWSAccessKeyId=Q6K3SCWMLYAKIAJXAAYQ&LastUpdatedAfter=2012-10-12T11%3A11%3A54-05%3A00&MarketplaceIdList.Id.1=ATVPDKIKX0DER&MarketplaceIdList.Id.2=KIKX0DERATVPD&SellerId=J4UBGSWCA31UTJ&SignatureMethod=HmacSHA256&SignatureVersion=2&Timestamp=2012-10-12T15%3A14%3A52-05%3A00' } + + let(:signer) { Signer.new({}) } + + it 'should default verb to POST' do + signer.verb.should == 'POST' + end + + it 'should default host to mws.amazonservices.com' do + signer.host.should == 'mws.amazonservices.com' + end + + it 'should default path to /' do + signer.path.should == '/' + end + + it 'should accept overrides to verb via the method or verb options' do + [ :method, :verb ].each do | key | + Signer.new({key => 'get'}).verb.should == 'GET' + end + end + + it 'should accept overrides to host via the host option' do + Signer.new(host: 'MWS.AmazonServices.DE').host.should == 'mws.amazonservices.de' + end + + it 'should accept overrides to path via the path option' do + Signer.new(path: '/Foo/Bar').path.should == '/Foo/Bar' + end + + it 'should accept secret values via the secret option' do + secret = '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq' + Signer.new(secret: secret).secret.should == secret + end + + it 'should correctly calculate a signature for the provided query and secret' do + signer.signature(query, '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq').should == 'jsOaccLC2MUFSUh5Lz7DdSA1+2//98LnUNp/b8xFi+0=' + end + + it 'should correctly calculate a signature for the provided query and default secret' do + Signer.new(secret: '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq').signature(query).should == 'jsOaccLC2MUFSUh5Lz7DdSA1+2//98LnUNp/b8xFi+0=' + end + + it 'should correclty sign the provided query and secret' do + signature = URI.encode_www_form_component signer.signature(query, '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq') + signer.sign(query, '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq').should == "#{query}&Signature=#{signature}" + end + + it 'should correctly sign the provided query and default secret' do + signer = Signer.new(secret: '53kddzBej7O7I5Yx9drGrXEUbzq/NskSrW4m5ncq') + signature = URI.encode_www_form_component signer.signature(query) + signer.sign(query).should == "#{query}&Signature=#{signature}" + end + + end + +end \ No newline at end of file diff --git a/spec/mws/utils_spec.rb b/spec/mws/utils_spec.rb new file mode 100644 index 0000000..f4f4886 --- /dev/null +++ b/spec/mws/utils_spec.rb @@ -0,0 +1,147 @@ +require 'spec_helper' + +module Mws + + module Foo + + module Bar + + class Baz + + def initialize(quk) + @quk = quk + end + + end + + class Quk + + def initialize(bar) + @bar = bar + end + + end + + end + + end + + describe Utils do + + context '.camelize' do + + it 'should properly camelize nil' do + Utils.camelize(nil).should be nil + Utils.camelize(nil, false).should be nil + end + + it 'should properly camelize the empty string' do + Utils.camelize('').should == '' + Utils.camelize('', false) == '' + end + + it 'should trim whitespace from the string' do + Utils.camelize(' ').should == '' + Utils.camelize(' ', false) == '' + Utils.camelize(' foo_bar_baz ').should == 'FooBarBaz' + Utils.camelize(' foo_bar_baz ', false).should == 'fooBarBaz' + end + + it 'should properly camelize single segment names' do + Utils.camelize('foo').should == 'Foo' + Utils.camelize('foo', false).should == 'foo' + end + + it 'should properly camelize multi-segment names' do + Utils.camelize('foo_bar_baz').should == 'FooBarBaz' + Utils.camelize('foo_bar_baz', false).should == 'fooBarBaz' + end + + it 'should properly camelize mixed case multi-segment names' do + Utils.camelize('fOO_BAR_BAZ').should == 'FooBarBaz' + Utils.camelize('fOO_BAR_BAZ', false).should == 'fooBarBaz' + end + + end + + context '.underscore' do + + it 'should properly underscore nil' do + Utils.underscore(nil).should be nil + end + + it 'should properly camelize the empty string' do + Utils.underscore('').should == '' + end + + it 'should trim whitespace from the string' do + Utils.underscore(' ').should == '' + Utils.underscore(' FooBarBaz ').should == 'foo_bar_baz' + end + + it 'should properly underscore single-segment names' do + Utils.underscore('Foo').should == 'foo' + Utils.underscore('foo').should == 'foo' + end + + it 'should properly underscore multi-segment names' do + Utils.underscore('FooBarBaz').should == 'foo_bar_baz' + end + + end + + context '.uri_escape' do + + { + ' ' => '20', + '"' => '22', + '#' => '23', + '$' => '24', + '%' => '25', + '&' => '26', + '+' => '2B', + ',' => '2C', + '/' => '2F', + ':' => '3A', + ';' => '3B', + '<' => '3C', + '=' => '3D', + '>' => '3E', + '?' => '3F', + '@' => '40', + '[' => '5B', + '\\' => '5C', + ']' => '5D', + '^' => '5E', + '{' => '7B', + '|' => '7C', + '}' => '7D' + }.each do | key, value | + it "should properly escape '#{key}' as '%#{value}'" do + Utils.uri_escape("foo#{key}bar").should == "foo%#{value}bar" + end + end + + end + + context '.alias' do + + before(:all) do + Utils.alias Mws, Mws::Foo::Bar, :Baz, :Quk + end + + it 'should create aliases of the specified constants' do + Mws::Baz.should == Mws::Foo::Bar::Baz + Mws::Quk.should == Mws::Foo::Bar::Quk + end + + it 'should create constructor shortcuts' do + Mws::Baz('quk').should be_a Mws::Foo::Bar::Baz + Mws::Quk('baz').should be_a Mws::Foo::Bar::Quk + end + + end + + end + +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..bd14c15 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,10 @@ +require 'uri' +require 'time' +require 'active_support/all' + +require 'simplecov' +SimpleCov.start do + add_filter 'spec' +end + +require 'mws' \ No newline at end of file From fe32d7cf22623c2b2d74f034c9d90440bc3101cb Mon Sep 17 00:00:00 2001 From: tducsai Date: Thu, 18 Jul 2013 07:18:13 -0700 Subject: [PATCH 02/10] amend with reports API get report action to get a batch of inventory items --- lib/mws/apis.rb | 1 + lib/mws/apis/reports.rb | 33 +++++++++++++++++++++++++++++++++ lib/mws/connection.rb | 30 +++++++++++++++++++----------- 3 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 lib/mws/apis/reports.rb diff --git a/lib/mws/apis.rb b/lib/mws/apis.rb index 1b041d8..ecb14ce 100644 --- a/lib/mws/apis.rb +++ b/lib/mws/apis.rb @@ -2,5 +2,6 @@ module Mws::Apis autoload :Feeds, 'mws/apis/feeds' autoload :Orders, 'mws/apis/orders' + autoload :Reports, 'mws/apis/reports' end diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb new file mode 100644 index 0000000..a7653c3 --- /dev/null +++ b/lib/mws/apis/reports.rb @@ -0,0 +1,33 @@ +class Mws::Apis::Reports + + require "csv" + + def initialize(connection, overrides={}) + @connection = connection + @param_defaults = { + market: 'ATVPDKIKX0DER' + }.merge overrides + @option_defaults = { + version: '2009-01-01' + } + end + + def get(params={}) + #params[:markets] ||= [ params.delete(:markets) || params.delete(:market) || @param_defaults[:market] ].flatten.compact + options = @option_defaults.merge action: 'GetReport' + + doc = @connection.get "/", params, options + + doc.gsub! /\r\n?/, "\n" + + begin + parsed_report = doc.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } + rescue CSV::MalformedCSVError + puts "failed to parse report line" + end + + header = parsed_report.shift + parsed_report.map { |item| Hash[header.zip(item)] } + end + +end diff --git a/lib/mws/connection.rb b/lib/mws/connection.rb index a2ec28b..397a26c 100644 --- a/lib/mws/connection.rb +++ b/lib/mws/connection.rb @@ -8,7 +8,7 @@ module Mws class Connection - attr_reader :merchant, :orders, :feeds + attr_reader :merchant, :orders, :feeds, :reports def initialize(overrides) @log = Logging.logger[self] @@ -22,6 +22,7 @@ def initialize(overrides) raise Mws::Errors::ValidationError, 'A secret key must be specified.' if @secret.nil? @orders = Apis::Orders.new self @feeds = Apis::Feeds::Api.new self + @reports = Apis::Reports.new self end def get(path, params, overrides) @@ -36,14 +37,21 @@ def post(path, params, body, overrides) def request(method, path, params, body, overrides) query = Query.new({ - action: overrides[:action], - version: overrides[:version], - merchant: @merchant, - access: @access, - list_pattern: overrides.delete(:list_pattern) - }.merge(params)) + action: overrides[:action], + version: overrides[:version], + merchant: @merchant, + access: @access, + list_pattern: overrides.delete(:list_pattern) + }.merge(params)) + + puts "QUERY method => #{method}" + puts "QUERY path => #{path}" + puts "QUERY => #{query.inspect}" + signer = Signer.new method: method, host: @host, path: path, secret: @secret - parse response_for(method, path, signer.sign(query), body), overrides + response = response_for(method, path, signer.sign(query), body) + + parse(response, overrides) || response end def response_for(method, path, query, body) @@ -59,7 +67,7 @@ def response_for(method, path, query, body) @log.debug "Hash:\n#{req['Content-MD5']}\n" req.body = body end - res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do | http | + res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.request req end raise Errors::ServerError.new(code: res.code, message: res.msg) if res.body.nil? @@ -70,12 +78,12 @@ def response_for(method, path, query, body) def parse(body, overrides) doc = Nokogiri::XML(body) doc.remove_namespaces! - doc.xpath('/ErrorResponse/Error').each do | error | + doc.xpath('/ErrorResponse/Error').each do |error| options = {} error.element_children.each { |node| options[node.name.downcase.to_sym] = node.text } raise Errors::ServerError.new(options) end - result = doc.xpath((overrides[:xpath] || '/%{action}Response/%{action}Result') % overrides ).first + result = doc.xpath((overrides[:xpath] || '/%{action}Response/%{action}Result') % overrides).first result end From 823093c8533eb36a439358969e835a59badb07d3 Mon Sep 17 00:00:00 2001 From: tducsai Date: Fri, 19 Jul 2013 08:44:54 -0700 Subject: [PATCH 03/10] add API Reports/GetReport action to pull products from a specific seller --- lib/mws/apis.rb | 1 + lib/mws/apis/reports.rb | 32 ++++++++++++++++++++++++++++++++ lib/mws/connection.rb | 6 ++++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 lib/mws/apis/reports.rb diff --git a/lib/mws/apis.rb b/lib/mws/apis.rb index 1b041d8..ecb14ce 100644 --- a/lib/mws/apis.rb +++ b/lib/mws/apis.rb @@ -2,5 +2,6 @@ module Mws::Apis autoload :Feeds, 'mws/apis/feeds' autoload :Orders, 'mws/apis/orders' + autoload :Reports, 'mws/apis/reports' end diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb new file mode 100644 index 0000000..db6ed26 --- /dev/null +++ b/lib/mws/apis/reports.rb @@ -0,0 +1,32 @@ +class Mws::Apis::Reports + + require "csv" + + def initialize(connection, overrides={}) + @connection = connection + @param_defaults = { + market: 'ATVPDKIKX0DER' + }.merge overrides + @option_defaults = { + version: '2009-01-01' + } + end + + # Reports / GetReport Action, required parameter: report_id + # Get and parse a generated _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ report result + def get(params={}) + options = @option_defaults.merge action: 'GetReport' + doc = @connection.get "/", params, options + + doc.gsub! /\r\n?/, "\n" + begin + parsed_report = doc.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } + rescue CSV::MalformedCSVError + puts "failed to parse report line" + end + + header = parsed_report.shift + parsed_report.map { |item| Hash[header.zip(item)] } + end + +end diff --git a/lib/mws/connection.rb b/lib/mws/connection.rb index a2ec28b..eced656 100644 --- a/lib/mws/connection.rb +++ b/lib/mws/connection.rb @@ -8,7 +8,7 @@ module Mws class Connection - attr_reader :merchant, :orders, :feeds + attr_reader :merchant, :orders, :feeds, :reports def initialize(overrides) @log = Logging.logger[self] @@ -22,6 +22,7 @@ def initialize(overrides) raise Mws::Errors::ValidationError, 'A secret key must be specified.' if @secret.nil? @orders = Apis::Orders.new self @feeds = Apis::Feeds::Api.new self + @reports = Apis::Reports.new self end def get(path, params, overrides) @@ -43,7 +44,8 @@ def request(method, path, params, body, overrides) list_pattern: overrides.delete(:list_pattern) }.merge(params)) signer = Signer.new method: method, host: @host, path: path, secret: @secret - parse response_for(method, path, signer.sign(query), body), overrides + response = response_for(method, path, signer.sign(query), body) + parse(response, overrides) || response end def response_for(method, path, query, body) From 3d2db5cc266404679d06edf5b96a3b1b4a01037a Mon Sep 17 00:00:00 2001 From: tducsai Date: Fri, 19 Jul 2013 14:46:49 -0700 Subject: [PATCH 04/10] separated methods to parse and convert add unit test --- lib/mws/apis/reports.rb | 19 ++++++++++-------- spec/mws/apis/reports_spec.rb | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 spec/mws/apis/reports_spec.rb diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb index db6ed26..47f5085 100644 --- a/lib/mws/apis/reports.rb +++ b/lib/mws/apis/reports.rb @@ -13,18 +13,21 @@ def initialize(connection, overrides={}) end # Reports / GetReport Action, required parameter: report_id - # Get and parse a generated _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ report result + # Get and parse a formerly generated _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ report result def get(params={}) options = @option_defaults.merge action: 'GetReport' - doc = @connection.get "/", params, options + lines = @connection.get "/", params, options + parsed_report = parse_report(lines) + convert_to_hash(parsed_report) + end - doc.gsub! /\r\n?/, "\n" - begin - parsed_report = doc.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } - rescue CSV::MalformedCSVError - puts "failed to parse report line" - end + def parse_report(lines) + lines.gsub! /\r\n?/, "\n" + parsed_report = lines.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } + parsed_report + end + def convert_to_hash(parsed_report) header = parsed_report.shift parsed_report.map { |item| Hash[header.zip(item)] } end diff --git a/spec/mws/apis/reports_spec.rb b/spec/mws/apis/reports_spec.rb new file mode 100644 index 0000000..5966856 --- /dev/null +++ b/spec/mws/apis/reports_spec.rb @@ -0,0 +1,37 @@ +# encoding: utf-8 + +require 'spec_helper' + +module Mws::Apis + + describe Reports do + + let(:connection) do + Mws::Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + end + + let(:reports_api) do + connection.reports + end + + it "should parse tab delimited lines correctly" do + input = "sku asin price quantity +GY-8YTI-6CDC B007DCI0E6 47.00 2 +II-4545-W3B6 B004UBTEIO 39.00 3" + output = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] + reports_api.parse_report(input).should eq output + end + + it "should convert arrays to hash using header line as keys" do + input = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] + output = [{"sku"=>"GY-8YTI-6CDC", "asin"=>"B007DCI0E6", "price"=>"47.00", "quantity"=>"2"}, {"sku"=>"II-4545-W3B6", "asin"=>"B004UBTEIO", "price"=>"39.00", "quantity"=>"3"}] + reports_api.convert_to_hash(input).should eq output + end + + end + +end \ No newline at end of file From b5399e949b4393913dd201f0d28c8e02cac0a977 Mon Sep 17 00:00:00 2001 From: tducsai Date: Sat, 20 Jul 2013 05:38:04 -0700 Subject: [PATCH 05/10] report request, get report request actions --- .idea/.name | 1 + .idea/.rakeTasks | 7 ++++++ .idea/encodings.xml | 5 +++++ .idea/misc.xml | 5 +++++ .idea/modules.xml | 9 ++++++++ .idea/scopes/scope_settings.xml | 5 +++++ .idea/vcs.xml | 7 ++++++ .idea/yazzey-mws.iml | 35 +++++++++++++++++++++++++++++ lib/mws/apis/reports.rb | 39 +++++++++++++++++++++++++-------- lib/mws/connection.rb | 1 + lib/mws/query.rb | 4 ++-- 11 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 .idea/.name create mode 100644 .idea/.rakeTasks create mode 100644 .idea/encodings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/scopes/scope_settings.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/yazzey-mws.iml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..c07b53a --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +yazzey-mws \ No newline at end of file diff --git a/.idea/.rakeTasks b/.idea/.rakeTasks new file mode 100644 index 0000000..25be9d6 --- /dev/null +++ b/.idea/.rakeTasks @@ -0,0 +1,7 @@ + + diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..e206d70 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..19bc37b --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..773525a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml new file mode 100644 index 0000000..922003b --- /dev/null +++ b/.idea/scopes/scope_settings.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..c80f219 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/yazzey-mws.iml b/.idea/yazzey-mws.iml new file mode 100644 index 0000000..677d068 --- /dev/null +++ b/.idea/yazzey-mws.iml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb index a7653c3..c5510be 100644 --- a/lib/mws/apis/reports.rb +++ b/lib/mws/apis/reports.rb @@ -12,20 +12,41 @@ def initialize(connection, overrides={}) } end - def get(params={}) - #params[:markets] ||= [ params.delete(:markets) || params.delete(:market) || @param_defaults[:market] ].flatten.compact - options = @option_defaults.merge action: 'GetReport' + def request_report(params={}) + options = @option_defaults.merge action: 'RequestReport' + params.merge! report_type: "_GET_FLAT_FILE_OPEN_LISTINGS_DATA_" + doc = @connection.get("/", params, options) + report_request_id = doc.xpath("/RequestReportResponse/RequestReportResult/ReportRequestInfo[1]/ReportRequestId").text + end + def get_report_request(report_request_id, params={}) + options = @option_defaults.merge action: 'GetReportRequestList' + params.merge! :"report_request_id_list.id.1" => report_request_id doc = @connection.get "/", params, options + request_info = doc.xpath("/GetReportRequestListResponse/GetReportRequestListResult/ReportRequestInfo[1]").first + status = request_info.xpath("ReportProcessingStatus").text + report_id = request_info.xpath("GeneratedReportId").text + (status == "_DONE_ ") ? report_id : nil + end - doc.gsub! /\r\n?/, "\n" + # Reports / GetReport Action, required parameter: report_id + # Get and parse a formerly generated _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ report result + def get_report(params={}) + options = @option_defaults.merge action: 'GetReport' + lines = @connection.get "/", params, options + parsed_report = parse_report(lines) + convert_to_hash(parsed_report) + end + + private - begin - parsed_report = doc.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } - rescue CSV::MalformedCSVError - puts "failed to parse report line" - end + def parse_report(lines) + lines.gsub! /\r\n?/, " \ n " + parsed_report = lines.split(" \ n ").map { |line| CSV.parse_line(line, col_sep: " \ t ") } + parsed_report + end + def convert_to_hash(parsed_report) header = parsed_report.shift parsed_report.map { |item| Hash[header.zip(item)] } end diff --git a/lib/mws/connection.rb b/lib/mws/connection.rb index 397a26c..5c08f5c 100644 --- a/lib/mws/connection.rb +++ b/lib/mws/connection.rb @@ -44,6 +44,7 @@ def request(method, path, params, body, overrides) list_pattern: overrides.delete(:list_pattern) }.merge(params)) + puts "QUERY params => #{params.inspect}" puts "QUERY method => #{method}" puts "QUERY path => #{path}" puts "QUERY => #{query.inspect}" diff --git a/lib/mws/query.rb b/lib/mws/query.rb index 37bf48a..eb81d61 100644 --- a/lib/mws/query.rb +++ b/lib/mws/query.rb @@ -13,7 +13,7 @@ def initialize(overrides) options[:seller_id] ||= options.delete(:merchant) || options.delete(:seller) options[:marketplace_id] ||= options.delete(:markets) || [] list_pattern = options.delete(:list_pattern) || '%{key}List.%{ext}.%d' - + @params = Hash[options.inject({}) do | params, entry | key = normalize_key entry.first if entry.last.respond_to? :each_with_index @@ -35,7 +35,7 @@ def to_s private def normalize_key(key) - Mws::Utils.camelize(key).sub /^Aws/, 'AWS' + Mws::Utils.camelize(key).sub(/^Aws/, 'AWS').sub(".id.", ".Id.") end def normalize_val(value) From b02240c9a3baf2c7f55721cff6e6bd40974acc5d Mon Sep 17 00:00:00 2001 From: tducsai Date: Sat, 20 Jul 2013 07:10:49 -0700 Subject: [PATCH 06/10] get reports count action --- lib/mws/apis/reports.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb index c5510be..1dd6bf7 100644 --- a/lib/mws/apis/reports.rb +++ b/lib/mws/apis/reports.rb @@ -38,6 +38,12 @@ def get_report(params={}) convert_to_hash(parsed_report) end + def get_report_count(params={}) + options = @option_defaults.merge action: 'GetReportCount' + doc = @connection.get "/", params, options + count = doc.xpath("/GetReportCountResponse/GetReportCountResult/Count[1]").text + end + private def parse_report(lines) From 913256b88f7e91aff51b7ea4c0abb9b701654e0a Mon Sep 17 00:00:00 2001 From: tducsai Date: Sun, 21 Jul 2013 07:23:48 -0700 Subject: [PATCH 07/10] add rubymine specific files to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3ef027c..bbd3cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/tmp test/version_tmp tmp *.sublime-workspace +.idea From 0530a83419863e8dc704112b9e94a6d427899e5d Mon Sep 17 00:00:00 2001 From: tducsai Date: Sun, 21 Jul 2013 07:25:35 -0700 Subject: [PATCH 08/10] remove rubymine specific files from repo --- .idea/.name | 1 - .idea/.rakeTasks | 7 ------- .idea/encodings.xml | 5 ----- .idea/misc.xml | 5 ----- .idea/modules.xml | 9 --------- .idea/scopes/scope_settings.xml | 5 ----- .idea/vcs.xml | 7 ------- .idea/yazzey-mws.iml | 35 --------------------------------- 8 files changed, 74 deletions(-) delete mode 100644 .idea/.name delete mode 100644 .idea/.rakeTasks delete mode 100644 .idea/encodings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/scopes/scope_settings.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/yazzey-mws.iml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index c07b53a..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -yazzey-mws \ No newline at end of file diff --git a/.idea/.rakeTasks b/.idea/.rakeTasks deleted file mode 100644 index 25be9d6..0000000 --- a/.idea/.rakeTasks +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index e206d70..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 19bc37b..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 773525a..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml deleted file mode 100644 index 922003b..0000000 --- a/.idea/scopes/scope_settings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index c80f219..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/.idea/yazzey-mws.iml b/.idea/yazzey-mws.iml deleted file mode 100644 index 677d068..0000000 --- a/.idea/yazzey-mws.iml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 6d20f76a31c342701854c3a948307e0ba066df81 Mon Sep 17 00:00:00 2001 From: tducsai Date: Sun, 21 Jul 2013 11:42:09 -0700 Subject: [PATCH 09/10] Reports API: basic actions, flat_file_open_listings_data request, get report action Unit tests Update Readme with examples --- README.md | 21 ++++ lib/mws/apis/reports.rb | 60 +---------- lib/mws/apis/reports/api.rb | 39 ++++++++ .../reports/flat_file_open_listings_data.rb | 46 +++++++++ lib/mws/connection.rb | 2 +- spec/mws/apis/reports/api_spec.rb | 99 +++++++++++++++++++ .../flat_file_open_listings_data_spec.rb | 60 +++++++++++ spec/mws/apis/reports_spec.rb | 37 ------- 8 files changed, 269 insertions(+), 95 deletions(-) create mode 100644 lib/mws/apis/reports/api.rb create mode 100644 lib/mws/apis/reports/flat_file_open_listings_data.rb create mode 100644 spec/mws/apis/reports/api_spec.rb create mode 100644 spec/mws/apis/reports/flat_file_open_listings_data_spec.rb delete mode 100644 spec/mws/apis/reports_spec.rb diff --git a/README.md b/README.md index 6a9bc04..3cd00c3 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,27 @@ Example: Get the results for a submission: _For an example of putting it all together check out the 'scripts/catalog-workflow'_ + +Access the Reports Api: + + reports_api = mws.reports + +Get products from a specific seller (http://stackoverflow.com/questions/13477797/get-products-from-a-specific-seller-from-amazon-via-api): + + reports_api.flat_file_open_listings_data.request + => "7589360560" # returns report request id + + reports_api.get_report_request("7589360560") + => nil # returns nil until report is uncompleted + + reports_api.get_report_request("7589360650") + => "11760935522" # returns report_id when report is ready + + reports_api.flat_file_open_listings_data.get("11760935522") + => [ {"sku"=>"GY-8IYT-2DCD", "asin"=>"B027DCI0D2", "price"=>"47.00", "quantity"=>"2"}, + {"sku"=>"II-2561-BW36", "asin"=>"B104BUTEIO", "price"=>"39.00", "quantity"=>"3"} ] + + ## Contributing 1. Fork it diff --git a/lib/mws/apis/reports.rb b/lib/mws/apis/reports.rb index 1dd6bf7..97ffd74 100644 --- a/lib/mws/apis/reports.rb +++ b/lib/mws/apis/reports.rb @@ -1,60 +1,6 @@ -class Mws::Apis::Reports +module Mws::Apis::Reports - require "csv" - - def initialize(connection, overrides={}) - @connection = connection - @param_defaults = { - market: 'ATVPDKIKX0DER' - }.merge overrides - @option_defaults = { - version: '2009-01-01' - } - end - - def request_report(params={}) - options = @option_defaults.merge action: 'RequestReport' - params.merge! report_type: "_GET_FLAT_FILE_OPEN_LISTINGS_DATA_" - doc = @connection.get("/", params, options) - report_request_id = doc.xpath("/RequestReportResponse/RequestReportResult/ReportRequestInfo[1]/ReportRequestId").text - end - - def get_report_request(report_request_id, params={}) - options = @option_defaults.merge action: 'GetReportRequestList' - params.merge! :"report_request_id_list.id.1" => report_request_id - doc = @connection.get "/", params, options - request_info = doc.xpath("/GetReportRequestListResponse/GetReportRequestListResult/ReportRequestInfo[1]").first - status = request_info.xpath("ReportProcessingStatus").text - report_id = request_info.xpath("GeneratedReportId").text - (status == "_DONE_ ") ? report_id : nil - end - - # Reports / GetReport Action, required parameter: report_id - # Get and parse a formerly generated _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ report result - def get_report(params={}) - options = @option_defaults.merge action: 'GetReport' - lines = @connection.get "/", params, options - parsed_report = parse_report(lines) - convert_to_hash(parsed_report) - end - - def get_report_count(params={}) - options = @option_defaults.merge action: 'GetReportCount' - doc = @connection.get "/", params, options - count = doc.xpath("/GetReportCountResponse/GetReportCountResult/Count[1]").text - end - - private - - def parse_report(lines) - lines.gsub! /\r\n?/, " \ n " - parsed_report = lines.split(" \ n ").map { |line| CSV.parse_line(line, col_sep: " \ t ") } - parsed_report - end - - def convert_to_hash(parsed_report) - header = parsed_report.shift - parsed_report.map { |item| Hash[header.zip(item)] } - end + autoload :Api, 'mws/apis/reports/api' + autoload :FlatFileOpenListingsData, 'mws/apis/reports/flat_file_open_listings_data' end diff --git a/lib/mws/apis/reports/api.rb b/lib/mws/apis/reports/api.rb new file mode 100644 index 0000000..16bc775 --- /dev/null +++ b/lib/mws/apis/reports/api.rb @@ -0,0 +1,39 @@ +module Mws::Apis::Reports + + class Api + + attr_reader :flat_file_open_listings_data + + def initialize(connection, overrides={}) + raise Mws::Errors::ValidationError, 'A connection is required.' if connection.nil? + @connection = connection + @param_defaults = { + market: 'ATVPDKIKX0DER' + }.merge overrides + @option_defaults = { + version: '2009-01-01' + } + + @flat_file_open_listings_data = FlatFileOpenListingsData.new(connection) + end + + # Gets status of a formerly initiated report generation, required parameter: report_request_id + def get_report_request(report_request_id, params={}) + options = @option_defaults.merge action: 'GetReportRequestList' + params.merge! :"report_request_id_list.id.1" => report_request_id + doc = @connection.get "/", params, options + request_info = doc.xpath("/GetReportRequestListResponse/GetReportRequestListResult/ReportRequestInfo[1]").first + status = request_info.xpath("ReportProcessingStatus").text + report_id = request_info.xpath("GeneratedReportId").text + (status == "_DONE_") ? report_id : nil + end + + def get_report_count(params={}) + options = @option_defaults.merge action: 'GetReportCount' + doc = @connection.get "/", params, options + count = doc.xpath("/GetReportCountResponse/GetReportCountResult/Count[1]").text.to_i + end + + end + +end diff --git a/lib/mws/apis/reports/flat_file_open_listings_data.rb b/lib/mws/apis/reports/flat_file_open_listings_data.rb new file mode 100644 index 0000000..1b61d4a --- /dev/null +++ b/lib/mws/apis/reports/flat_file_open_listings_data.rb @@ -0,0 +1,46 @@ +class Mws::Apis::Reports::FlatFileOpenListingsData + + require "csv" + + def initialize(connection, overrides={}) + @connection = connection + @param_defaults = { + market: 'ATVPDKIKX0DER' + }.merge overrides + @option_defaults = { + version: '2009-01-01' + } + end + + # Initiates a "flat file open listings data" report generation + def request(params={}) + options = @option_defaults.merge action: 'RequestReport' + params.merge! report_type: "_GET_FLAT_FILE_OPEN_LISTINGS_DATA_" + doc = @connection.get("/", params, options) + request_info = doc.xpath("/RequestReportResponse/RequestReportResult/ReportRequestInfo[1]").first + status = request_info.xpath("ReportProcessingStatus").text + status == "_SUBMITTED_" ? request_info.xpath("ReportRequestId").text : nil + end + + # Gets and parses a formerly generated "flat file open listings data" report result + # Required parameter: report_id which can be obtained by get_report_request method + def get(report_id, params={}) + options = @option_defaults.merge action: 'GetReport' + params.merge! :"report_id" => report_id + lines = @connection.get "/", params, options + parsed_report = parse_report(lines) + convert_to_hash(parsed_report) + end + + def parse_report(report_lines) + report_lines.gsub! /\r\n?/, "\n" + parsed_report = report_lines.split("\n").map { |line| CSV.parse_line(line, col_sep: "\t") } + parsed_report + end + + def convert_to_hash(parsed_report) + header = parsed_report.shift + parsed_report.map { |item| Hash[header.zip(item)] } + end + +end diff --git a/lib/mws/connection.rb b/lib/mws/connection.rb index eced656..73e1b41 100644 --- a/lib/mws/connection.rb +++ b/lib/mws/connection.rb @@ -22,7 +22,7 @@ def initialize(overrides) raise Mws::Errors::ValidationError, 'A secret key must be specified.' if @secret.nil? @orders = Apis::Orders.new self @feeds = Apis::Feeds::Api.new self - @reports = Apis::Reports.new self + @reports = Apis::Reports::Api.new self end def get(path, params, overrides) diff --git a/spec/mws/apis/reports/api_spec.rb b/spec/mws/apis/reports/api_spec.rb new file mode 100644 index 0000000..176486c --- /dev/null +++ b/spec/mws/apis/reports/api_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +module Mws::Apis::Reports + + describe Api do + + let(:connection) do + Mws::Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + end + + let(:reports_api) do + connection.reports + end + + it "should return generated report id for a report with DONE status" do + response_xml_body = < + + + + + false + + 7580976354 + _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ + 2013-07-20T11:19:01+00:00 + 2013-07-20T11:19:01+00:00 + false + 2013-07-20T11:19:01+00:00 + _DONE_ + 11743408783 + 2013-07-20T11:27:42+00:00 + 2013-07-20T11:28:03+00:00 + + + + 1b435755-d127-413e-9dd1-5c2f54cea33b + +" +END + connection.should_receive(:response_for).and_return { response_xml_body } + #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}) + reports_api.get_report_request("7580976354").should eq "11743408783" + end + + it "should return nil for an uncompleted report" do + response_xml_body = < + + + + + false + + 7589329190 + _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ + 2013-07-21T18:10:28+00:00 + 2013-07-21T18:10:28+00:00 + false + 2013-07-21T18:10:28+00:00 + _SUBMITTED_ + + + + 96241497-20bd-4edc-90f0-4e9dda39c340 + + +END + connection.should_receive(:response_for).and_return { response_xml_body } + #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) + reports_api.get_report_request("7589329190").should be nil + end + + + it "should return report count" do + response_xml_body = < + + + 7 + + + 87a113eb-18a8-4c46-874c-e6d740f750a8 + + +END + connection.should_receive(:response_for).and_return { response_xml_body } + #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) + reports_api.get_report_count.should be 7 + end + + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb b/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb new file mode 100644 index 0000000..94a92e2 --- /dev/null +++ b/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +module Mws::Apis::Reports + + describe FlatFileOpenListingsData do + + let(:connection) do + Mws::Connection.new( + merchant: 'GSWCJ4UBA31UTJ', + access: 'AYQAKIAJSCWMLYXAQ6K3', + secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' + ) + end + + let(:reports_flat_file_open_listings_data_api) do + connection.reports.flat_file_open_listings_data + end + + it "should request a report generation" do + response_xml_body = < + + + + 7589269186 + _GET_FLAT_FILE_OPEN_LISTINGS_DATA_ + 2013-07-21T18:00:06+00:00 + 2013-07-21T18:00:06+00:00 + false + 2013-07-21T18:00:06+00:00 + _SUBMITTED_ + + + + 5e529e1b-c0fa-438d-a602-704d5ab80728 + + +END + connection.should_receive(:response_for).and_return { response_xml_body } + #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) + reports_flat_file_open_listings_data_api.request.should eq "7589269186" + end + + it "should parse tab delimited lines of a report result" do + input = "sku asin price quantity +GY-8YTI-6CDC B007DCI0E6 47.00 2 +II-4545-W3B6 B004UBTEIO 39.00 3" + output = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] + reports_flat_file_open_listings_data_api.parse_report(input).should eq output + end + + it "should convert report arrays to hash using the header line as keys" do + input = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] + output = [{"sku" => "GY-8YTI-6CDC", "asin" => "B007DCI0E6", "price" => "47.00", "quantity" => "2"}, {"sku" => "II-4545-W3B6", "asin" => "B004UBTEIO", "price" => "39.00", "quantity" => "3"}] + reports_flat_file_open_listings_data_api.convert_to_hash(input).should eq output + end + + end + +end \ No newline at end of file diff --git a/spec/mws/apis/reports_spec.rb b/spec/mws/apis/reports_spec.rb deleted file mode 100644 index 5966856..0000000 --- a/spec/mws/apis/reports_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# encoding: utf-8 - -require 'spec_helper' - -module Mws::Apis - - describe Reports do - - let(:connection) do - Mws::Connection.new( - merchant: 'GSWCJ4UBA31UTJ', - access: 'AYQAKIAJSCWMLYXAQ6K3', - secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX' - ) - end - - let(:reports_api) do - connection.reports - end - - it "should parse tab delimited lines correctly" do - input = "sku asin price quantity -GY-8YTI-6CDC B007DCI0E6 47.00 2 -II-4545-W3B6 B004UBTEIO 39.00 3" - output = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] - reports_api.parse_report(input).should eq output - end - - it "should convert arrays to hash using header line as keys" do - input = [["sku", "asin", "price", "quantity"], ["GY-8YTI-6CDC", "B007DCI0E6", "47.00", "2"], ["II-4545-W3B6", "B004UBTEIO", "39.00", "3"]] - output = [{"sku"=>"GY-8YTI-6CDC", "asin"=>"B007DCI0E6", "price"=>"47.00", "quantity"=>"2"}, {"sku"=>"II-4545-W3B6", "asin"=>"B004UBTEIO", "price"=>"39.00", "quantity"=>"3"}] - reports_api.convert_to_hash(input).should eq output - end - - end - -end \ No newline at end of file From 21f67abc78a22df58669b2fdcab25e87464027db Mon Sep 17 00:00:00 2001 From: tducsai Date: Sun, 21 Jul 2013 11:49:05 -0700 Subject: [PATCH 10/10] remove comments from report specs --- spec/mws/apis/reports/api_spec.rb | 6 +----- spec/mws/apis/reports/flat_file_open_listings_data_spec.rb | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/spec/mws/apis/reports/api_spec.rb b/spec/mws/apis/reports/api_spec.rb index 176486c..c08be2f 100644 --- a/spec/mws/apis/reports/api_spec.rb +++ b/spec/mws/apis/reports/api_spec.rb @@ -43,7 +43,6 @@ module Mws::Apis::Reports " END connection.should_receive(:response_for).and_return { response_xml_body } - #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}) reports_api.get_report_request("7580976354").should eq "11743408783" end @@ -71,12 +70,10 @@ module Mws::Apis::Reports END connection.should_receive(:response_for).and_return { response_xml_body } - #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) reports_api.get_report_request("7589329190").should be nil end - - it "should return report count" do + it "should return reports count" do response_xml_body = < @@ -89,7 +86,6 @@ module Mws::Apis::Reports END connection.should_receive(:response_for).and_return { response_xml_body } - #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) reports_api.get_report_count.should be 7 end diff --git a/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb b/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb index 94a92e2..5fd75ac 100644 --- a/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb +++ b/spec/mws/apis/reports/flat_file_open_listings_data_spec.rb @@ -37,7 +37,6 @@ module Mws::Apis::Reports END connection.should_receive(:response_for).and_return { response_xml_body } - #connection.should_receive(:get).with("/", {:"report_request_id_list.id.1" => "7580976354"}, {:version => "2009-01-01", :action => "GetReportRequestList"}).and_return Nokogiri::XML(xml) reports_flat_file_open_listings_data_api.request.should eq "7589269186" end