mws-connect 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. data/.gitignore +19 -0
  2. data/.sublime-project +19 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +111 -0
  6. data/Rakefile +1 -0
  7. data/lib/mws.rb +34 -0
  8. data/lib/mws/apis.rb +6 -0
  9. data/lib/mws/apis/feeds.rb +20 -0
  10. data/lib/mws/apis/feeds/api.rb +103 -0
  11. data/lib/mws/apis/feeds/distance.rb +23 -0
  12. data/lib/mws/apis/feeds/feed.rb +114 -0
  13. data/lib/mws/apis/feeds/image_listing.rb +44 -0
  14. data/lib/mws/apis/feeds/inventory.rb +77 -0
  15. data/lib/mws/apis/feeds/measurement.rb +32 -0
  16. data/lib/mws/apis/feeds/money.rb +31 -0
  17. data/lib/mws/apis/feeds/price_listing.rb +48 -0
  18. data/lib/mws/apis/feeds/product.rb +173 -0
  19. data/lib/mws/apis/feeds/sale_price.rb +31 -0
  20. data/lib/mws/apis/feeds/shipping.rb +160 -0
  21. data/lib/mws/apis/feeds/submission_info.rb +45 -0
  22. data/lib/mws/apis/feeds/submission_result.rb +87 -0
  23. data/lib/mws/apis/feeds/transaction.rb +37 -0
  24. data/lib/mws/apis/feeds/weight.rb +19 -0
  25. data/lib/mws/apis/orders.rb +23 -0
  26. data/lib/mws/connection.rb +84 -0
  27. data/lib/mws/enum.rb +81 -0
  28. data/lib/mws/errors.rb +32 -0
  29. data/lib/mws/query.rb +45 -0
  30. data/lib/mws/serializer.rb +81 -0
  31. data/lib/mws/signer.rb +20 -0
  32. data/lib/mws/utils.rb +50 -0
  33. data/mws.gemspec +25 -0
  34. data/scripts/catalog-workflow +136 -0
  35. data/spec/mws/apis/feeds/api_spec.rb +229 -0
  36. data/spec/mws/apis/feeds/distance_spec.rb +43 -0
  37. data/spec/mws/apis/feeds/feed_spec.rb +92 -0
  38. data/spec/mws/apis/feeds/image_listing_spec.rb +109 -0
  39. data/spec/mws/apis/feeds/inventory_spec.rb +135 -0
  40. data/spec/mws/apis/feeds/measurement_spec.rb +84 -0
  41. data/spec/mws/apis/feeds/money_spec.rb +43 -0
  42. data/spec/mws/apis/feeds/price_listing_spec.rb +90 -0
  43. data/spec/mws/apis/feeds/product_spec.rb +264 -0
  44. data/spec/mws/apis/feeds/shipping_spec.rb +78 -0
  45. data/spec/mws/apis/feeds/submission_info_spec.rb +111 -0
  46. data/spec/mws/apis/feeds/submission_result_spec.rb +157 -0
  47. data/spec/mws/apis/feeds/transaction_spec.rb +64 -0
  48. data/spec/mws/apis/feeds/weight_spec.rb +43 -0
  49. data/spec/mws/apis/orders_spec.rb +9 -0
  50. data/spec/mws/connection_spec.rb +331 -0
  51. data/spec/mws/enum_spec.rb +166 -0
  52. data/spec/mws/query_spec.rb +104 -0
  53. data/spec/mws/serializer_spec.rb +187 -0
  54. data/spec/mws/signer_spec.rb +67 -0
  55. data/spec/mws/utils_spec.rb +147 -0
  56. data/spec/spec_helper.rb +10 -0
  57. metadata +220 -0
@@ -0,0 +1,81 @@
1
+ require 'nokogiri'
2
+
3
+ module Mws
4
+
5
+ class Serializer
6
+
7
+ def self.tree(name, parent, &block)
8
+ if parent
9
+ parent.send(name, &block)
10
+ parent.doc.root.to_xml
11
+ else
12
+ Nokogiri::XML::Builder.new do | xml |
13
+ xml.send(name, &block)
14
+ end.doc.root.to_xml
15
+ end
16
+ end
17
+
18
+ def self.leaf(name, parent, value, attributes)
19
+ if parent
20
+ parent.send(name, value, attributes)
21
+ parent.doc.root.to_xml
22
+ else
23
+ Nokogiri::XML::Builder.new do | xml |
24
+ xml.send(name, value, attributes)
25
+ end.doc.root.to_xml
26
+ end
27
+ end
28
+
29
+ def initialize(exceptions={})
30
+ @xml_exceptions = exceptions
31
+ @hash_exceptions = {}
32
+ exceptions.each do | key, value |
33
+ @hash_exceptions[value.to_sym] = key
34
+ end
35
+ end
36
+
37
+ def xml_for(name, data, builder, context=nil)
38
+ element = @xml_exceptions[name.to_sym] || Utils.camelize(name)
39
+ path = path_for name, context
40
+ if data.respond_to? :keys
41
+ builder.send(element) do | b |
42
+ data.each do | key, value |
43
+ xml_for(key, value, builder, path)
44
+ end
45
+ end
46
+ elsif data.respond_to? :each
47
+ data.each { |value| xml_for(name, value, builder, path) }
48
+ elsif data.respond_to? :to_xml
49
+ data.to_xml element, builder
50
+ else
51
+ builder.send element, data
52
+ end
53
+ end
54
+
55
+ def hash_for(node, context)
56
+ elements = node.elements()
57
+ return node.text unless elements.size > 0
58
+ res = {}
59
+ elements.each do | element |
60
+ name = @hash_exceptions[element.name.to_sym] || Utils.underscore(element.name).to_sym
61
+ path = path_for name, context
62
+ content = instance_exec element, path, &method(:hash_for)
63
+ if res.include? name
64
+ res[name] = [ res[name] ] unless res[name].instance_of? Array
65
+ res[name] << content
66
+ else
67
+ res[name] = content
68
+ end
69
+ end
70
+ res
71
+ end
72
+
73
+ private
74
+
75
+ def path_for(name, context=nil)
76
+ [ context, name ].compact.join '.'
77
+ end
78
+
79
+ end
80
+
81
+ end
data/lib/mws/signer.rb ADDED
@@ -0,0 +1,20 @@
1
+ class Mws::Signer
2
+
3
+ def initialize(options={})
4
+ @verb = (options[:method] || options[:verb] || 'POST').to_s.upcase
5
+ @host = (options[:host] || 'mws.amazonservices.com').to_s.downcase
6
+ @path = options[:path] || '/'
7
+ @secret = options[:secret]
8
+ end
9
+
10
+ def signature(query, secret=@secret)
11
+ digest = OpenSSL::Digest::Digest.new 'sha256'
12
+ message = [ @verb, @host, @path, query ].join "\n"
13
+ Base64::encode64(OpenSSL::HMAC.digest(digest, secret, message)).chomp
14
+ end
15
+
16
+ def sign(query, secret=@secret)
17
+ "#{query}&Signature=#{Mws::Utils.uri_escape signature(query, secret)}"
18
+ end
19
+
20
+ end
data/lib/mws/utils.rb ADDED
@@ -0,0 +1,50 @@
1
+ # This module contains a collection of generally useful methods that (currently) have no better place to live. They can
2
+ # either be referenced directly as module methods or be mixed in.
3
+ module Mws::Utils
4
+ extend self
5
+
6
+ # This method will derive a camelized name from the provided underscored name.
7
+ #
8
+ # @param [#to_s] name The underscored name to be camelized.
9
+ # @param [Boolean] uc_first True if and only if the first letter of the resulting camelized name should be
10
+ # capitalized.
11
+ #
12
+ # @return [String] The camelized name corresponding to the provided underscored name.
13
+ def camelize(name, uc_first=true)
14
+ return nil if name.nil?
15
+ name = name.to_s.strip
16
+ return name if name.empty?
17
+ parts = name.split '_'
18
+ assemble = lambda { |head, tail| head + tail.capitalize }
19
+ parts[0] = uc_first ? parts[0].capitalize : parts[0].downcase
20
+ parts.inject(&assemble)
21
+ end
22
+
23
+ def underscore(name)
24
+ return nil if name.nil?
25
+ name = name.to_s.strip
26
+ return name if name.empty?
27
+ name.gsub(/::/, '/')
28
+ .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
29
+ .gsub(/([a-z\d])([A-Z])/,'\1_\2')
30
+ .tr("-", "_")
31
+ .downcase
32
+ end
33
+
34
+ def uri_escape(value)
35
+ value.gsub /([^a-zA-Z0-9_.~-]+)/ do
36
+ '%' + $1.unpack('H2' * $1.bytesize).join('%').upcase
37
+ end
38
+ end
39
+
40
+ def alias(to, from, *constants)
41
+ constants.each do | name |
42
+ constant = from.const_get(name)
43
+ to.singleton_class.send(:define_method, name) do | *args, &block |
44
+ constant.new *args, &block
45
+ end
46
+ to.const_set(name, constant)
47
+ end
48
+ end
49
+
50
+ end
data/mws.gemspec ADDED
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'mws'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'mws-connect'
8
+ gem.version = Mws::VERSION
9
+ gem.authors = ['Sean M. Duncan', 'John E. Bailey']
10
+ gem.email = ['info@devmode.com']
11
+ gem.description = %q{The missing ruby client library for Amazon MWS}
12
+ gem.summary = %q{The missing ruby client library for Amazon MWS}
13
+ gem.homepage = 'http://github.com/devmode/mws'
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|specs?|feat(ures?)?)/})
18
+ gem.require_paths = ['lib']
19
+ gem.add_development_dependency 'rspec'
20
+ gem.add_development_dependency 'simplecov'
21
+ gem.add_development_dependency 'cucumber'
22
+ gem.add_development_dependency 'activesupport'
23
+ gem.add_dependency 'logging', '~> 1.8.0'
24
+ gem.add_dependency 'nokogiri', '~> 1.5.5'
25
+ end
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if ARGV.size < 3
4
+ puts "Usage: 'catalog-workflow merchant access secret'"
5
+ exit 1
6
+ end
7
+
8
+ require 'mws'
9
+ require 'logging'
10
+ require 'active_support/core_ext'
11
+
12
+ Logging.logger.root.appenders = Logging.appenders.stdout
13
+ Logging.logger.root.level = :debug
14
+
15
+ mws = Mws.connect(
16
+ merchant: ARGV[0],
17
+ access: ARGV[1],
18
+ secret: ARGV[2]
19
+ )
20
+
21
+ class Workflow
22
+
23
+ def initialize(queue)
24
+ @queue = queue
25
+ @handlers = []
26
+ end
27
+
28
+ def register(*deps, &handler)
29
+ @handlers << [ handler, deps ]
30
+ end
31
+
32
+ def complete(feed)
33
+ @handlers.each do | handler |
34
+ handler.last.delete feed
35
+ end
36
+ proceed
37
+ end
38
+
39
+ def proceed
40
+ @handlers.each_with_index do | handler, index |
41
+ if handler.last.empty?
42
+ @handlers[index] = nil
43
+ [ handler.first.call ].compact.flatten.each do | feed |
44
+ @queue << feed
45
+ end
46
+ end
47
+ end
48
+ @handlers.compact!
49
+ end
50
+
51
+ end
52
+
53
+ in_process_q = []
54
+ terminated_q = []
55
+
56
+ workflow = Workflow.new(in_process_q)
57
+
58
+ workflow.register do
59
+ product_feed = mws.feeds.products.add(
60
+ Mws::Product('2634897') do
61
+ tax_code 'A_GEN_TAX'
62
+ name "Rocketfish\u2122 6' In-Wall HDMI Cable"
63
+ brand "Rocketfish\u2122"
64
+ 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."
65
+ bullet_point 'Compatible with HDMI components'
66
+ bullet_point'Connects an HDMI source to an HDTV or projector with an HDMI input'
67
+ bullet_point 'Up to 15 Gbps bandwidth'
68
+ bullet_point'In-wall rated'
69
+ msrp 49.99, :usd
70
+ category :ce
71
+ details {
72
+ cable_or_adapter {
73
+ cable_length as_distance 6, :feet
74
+ }
75
+ }
76
+ end
77
+ )
78
+ workflow.register product_feed.id do
79
+ price_feed = mws.feeds.prices.add(
80
+ Mws::PriceListing('2634897', 49.99).on_sale(29.99, Time.now, 3.months.from_now)
81
+ )
82
+ image_feed = mws.feeds.images.add(
83
+ Mws::ImageListing('2634897', 'http://images.bestbuy.com/BestBuy_US/images/products/2634/2634897_sa.jpg', 'Main'),
84
+ Mws::ImageListing('2634897', 'http://images.bestbuy.com/BestBuy_US/images/products/2634/2634897cv1a.jpg', 'PT1')
85
+ )
86
+ shipping_feed = mws.feeds.shipping.add(
87
+ Mws::Shipping('2634897') {
88
+ restricted :alaska_hawaii, :standard, :po_box
89
+ adjust 4.99, :usd, :continental_us, :standard
90
+ replace 11.99, :usd, :continental_us, :expedited, :street
91
+ }
92
+ )
93
+ workflow.register price_feed.id, image_feed.id, shipping_feed.id do
94
+ inventory_feed = mws.feeds.inventory.add(
95
+ Mws::Inventory('2634897', quantity: 10, fulfillment_type: :mfn)
96
+ )
97
+ workflow.register inventory_feed.id do
98
+ puts 'The workflow is complete!'
99
+ end
100
+ inventory_feed.id
101
+ end
102
+ [ price_feed.id, image_feed.id, shipping_feed.id ]
103
+ end
104
+ product_feed.id
105
+ end
106
+
107
+ workflow.proceed
108
+
109
+ 50.times do
110
+ puts "In Process: #{in_process_q}"
111
+ mws.feeds.list(ids: in_process_q).each do | info |
112
+ puts "SubmissionId: #{info.id} Status: #{info.status}"
113
+ terminated_q << in_process_q.delete(info.id) if [:cancelled, :done].include? info.status
114
+ end unless in_process_q.empty?
115
+
116
+ puts "Terminated: #{terminated_q}"
117
+ unless terminated_q.empty?
118
+ id = terminated_q.shift
119
+ result = mws.feeds.get id
120
+ puts result.inspect
121
+ if result.messages_processed == result.count_for(:success)
122
+ workflow.complete result.transaction_id
123
+ end
124
+ end
125
+ puts "| Waiting |"
126
+ print '------------------------------------------------------------'
127
+ 60.times do |it|
128
+ sleep 1
129
+ print "\r"
130
+ prg = it + 1
131
+ rem = 60 - prg
132
+ prg.times { print '=' }
133
+ rem.times { print '-' }
134
+ end
135
+ print "\n"
136
+ end
@@ -0,0 +1,229 @@
1
+ require 'spec_helper'
2
+
3
+ module Mws::Apis::Feeds
4
+
5
+ class Api
6
+ attr_reader :defaults
7
+ end
8
+
9
+ describe Api do
10
+
11
+ let(:connection) do
12
+ Mws::Connection.new(
13
+ merchant: 'GSWCJ4UBA31UTJ',
14
+ access: 'AYQAKIAJSCWMLYXAQ6K3',
15
+ secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX'
16
+ )
17
+ end
18
+
19
+ let(:api) { Api.new(connection) }
20
+
21
+ context '.new' do
22
+
23
+ it 'should require connection' do
24
+ expect { Api.new(nil) }.to raise_error Mws::Errors::ValidationError, 'A connection is required.'
25
+ end
26
+
27
+ it 'should default version to 2009-01-01' do
28
+ api.defaults[:version].should == '2009-01-01'
29
+ end
30
+
31
+ it 'should initialize a products feed' do
32
+ TargetedApi.as_null_object
33
+ TargetedApi.should_receive(:new).with(anything, connection.merchant, :product)
34
+ api = Api.new(connection)
35
+ api.products.should_not be nil
36
+ end
37
+
38
+ it 'should initialize an images feed' do
39
+ TargetedApi.as_null_object
40
+ TargetedApi.should_receive(:new).with(anything, connection.merchant, :image)
41
+ api = Api.new(connection)
42
+ api.images.should_not be nil
43
+ end
44
+
45
+ it 'should initialize a prices feed' do
46
+ TargetedApi.as_null_object
47
+ TargetedApi.should_receive(:new).with(anything, connection.merchant, :price)
48
+ api = Api.new(connection)
49
+ api.prices.should_not be nil
50
+ end
51
+
52
+ it 'should initialize a shipping feed' do
53
+ TargetedApi.as_null_object
54
+ TargetedApi.should_receive(:new).with(anything, connection.merchant, :override)
55
+ api = Api.new(connection)
56
+ api.shipping.should_not be nil
57
+ end
58
+
59
+ it 'should initialize an inventory feed' do
60
+ TargetedApi.as_null_object
61
+ TargetedApi.should_receive(:new).with(anything, connection.merchant, :inventory)
62
+ api = Api.new(connection)
63
+ api.inventory.should_not be nil
64
+ end
65
+
66
+ end
67
+
68
+ context '#get' do
69
+
70
+ it 'should properly delegate to connection' do
71
+ connection.should_receive(:get).with('/', { feed_submission_id: 1 }, {
72
+ version: '2009-01-01',
73
+ action: 'GetFeedSubmissionResult',
74
+ xpath: 'AmazonEnvelope/Message'
75
+ }).and_return('a_node')
76
+ SubmissionResult.should_receive(:from_xml).with('a_node')
77
+ api.get(1)
78
+ end
79
+
80
+ end
81
+
82
+ context '#submit' do
83
+
84
+ it 'should properly delegate to connection' do
85
+ response = double(:response)
86
+ response.should_receive(:xpath).with('FeedSubmissionInfo').and_return(['a_result'])
87
+ connection.should_receive(:post).with('/', { feed_type: '_POST_INVENTORY_AVAILABILITY_DATA_' }, 'a_body', {
88
+ version: '2009-01-01',
89
+ action: 'SubmitFeed'
90
+ }).and_return(response)
91
+ SubmissionInfo.should_receive(:from_xml).with('a_result')
92
+ api.submit 'a_body', feed_type: :inventory
93
+ end
94
+
95
+ end
96
+
97
+ context '#list' do
98
+
99
+ it 'should handle a single submission id' do
100
+ response = double(:response)
101
+ response.should_receive(:xpath).with('FeedSubmissionInfo').and_return(['result_one'])
102
+ connection.should_receive(:get).with('/', { feed_submission_id: [ 1 ] }, {
103
+ version: '2009-01-01',
104
+ action: 'GetFeedSubmissionList'
105
+ }).and_return(response)
106
+ SubmissionInfo.should_receive(:from_xml) { | node | node }.once
107
+ api.list(id: 1).should == [ 'result_one' ]
108
+ end
109
+
110
+ it 'should handle a multiple submission ids' do
111
+ response = double(:response)
112
+ response.should_receive(:xpath).with('FeedSubmissionInfo').and_return([ 'result_one', 'result_two', 'result_three' ])
113
+ connection.should_receive(:get).with('/', { feed_submission_id: [ 1, 2, 3 ] }, {
114
+ version: '2009-01-01',
115
+ action: 'GetFeedSubmissionList'
116
+ }).and_return(response)
117
+ SubmissionInfo.should_receive(:from_xml) { | node | node }.exactly(3).times
118
+ api.list(ids: [ 1, 2, 3 ]).should == [ 'result_one', 'result_two', 'result_three' ]
119
+ end
120
+
121
+ end
122
+
123
+ context '#count' do
124
+
125
+ it 'should properly delegate to connection' do
126
+ count = double(:count)
127
+ count.should_receive(:text).and_return('5')
128
+ response = double(:response)
129
+ response.should_receive(:xpath).with('Count').and_return([ count ])
130
+ connection.should_receive(:get).with('/', {}, {
131
+ version: '2009-01-01',
132
+ action: 'GetFeedSubmissionCount'
133
+ }).and_return(response)
134
+ api.count.should == 5
135
+ end
136
+
137
+ end
138
+
139
+ end
140
+
141
+ describe TargetedApi do
142
+
143
+ let(:connection) do
144
+ Mws::Connection.new(
145
+ merchant: 'GSWCJ4UBA31UTJ',
146
+ access: 'AYQAKIAJSCWMLYXAQ6K3',
147
+ secret: 'Ubzq/NskSrW4m5ncq53kddzBej7O7IE5Yx9drGrX'
148
+ )
149
+ end
150
+
151
+ let(:api) { Api.new(connection) }
152
+
153
+ context '#add' do
154
+
155
+ it 'should properly delegate to #submit' do
156
+ api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :update, true).and_return('a_result')
157
+ api.products.add('resource_one', 'resource_two').should == 'a_result'
158
+ end
159
+
160
+ end
161
+
162
+ context '#update' do
163
+
164
+ it 'should properly delegate to #submit' do
165
+ api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :update).and_return('a_result')
166
+ api.products.update('resource_one', 'resource_two').should == 'a_result'
167
+ end
168
+
169
+ end
170
+
171
+ context '#patch' do
172
+
173
+ it 'should properly delegate to #submit for products' do
174
+ api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :partial_update).and_return('a_result')
175
+ api.products.patch('resource_one', 'resource_two').should == 'a_result'
176
+ end
177
+
178
+ it 'should not be supported for feeds other than products' do
179
+ expect { api.images.patch('resource_one', 'resource_two') }.to raise_error 'Operation Type not supported.'
180
+ end
181
+
182
+ end
183
+
184
+ context '#delete' do
185
+
186
+ it 'should properly delegate to #submit' do
187
+ api.products.should_receive(:submit).with([ 'resource_one', 'resource_two' ], :delete).and_return('a_result')
188
+ api.products.delete('resource_one', 'resource_two').should == 'a_result'
189
+ end
190
+
191
+ end
192
+
193
+ context '#submit' do
194
+
195
+ it 'should properly construct the feed and delegate to feeds' do
196
+ resource = double :resource
197
+ resource.stub(:to_xml)
198
+ resource.stub(:sku).and_return('a_sku')
199
+ resource.stub(:operation_type).and_return(:update)
200
+ feed_xml = Nokogiri::XML::Builder.new do
201
+ AmazonEnvelope('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:noNamespaceSchemaLocation' => 'amznenvelope.xsd') {
202
+ Header {
203
+ DocumentVersion '1.01'
204
+ MerchantIdentifier 'GSWCJ4UBA31UTJ'
205
+ }
206
+ MessageType 'Product'
207
+ PurgeAndReplace false
208
+ Message {
209
+ MessageID 1
210
+ OperationType 'Update'
211
+ }
212
+ }
213
+ end.doc.to_xml
214
+ submission_info = double(:submission_info).as_null_object
215
+ api.should_receive(:submit).with(feed_xml, feed_type: Feed::Type.PRODUCT, purge_and_replace: false).and_return(submission_info)
216
+ tx = api.products.submit [ resource ], :update
217
+ tx.items.size.should == 1
218
+ item = tx.items.first
219
+ item.id.should == 1
220
+ item.sku.should == 'a_sku'
221
+ item.operation.should == :update
222
+ item.qualifier.should be nil
223
+ end
224
+
225
+ end
226
+
227
+ end
228
+
229
+ end