shipvine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,22 @@
1
+ require "shipvine/version"
2
+
3
+ require 'httparty'
4
+ require 'gyoku'
5
+ require 'nori'
6
+
7
+ require 'shipvine/error'
8
+ require 'shipvine/client'
9
+ require 'shipvine/base'
10
+ require 'shipvine/fulfillment_request'
11
+ require 'shipvine/inbound_shipment'
12
+ require 'shipvine/outbound_shipment'
13
+ require 'shipvine/bill_of_lading'
14
+ require 'shipvine/item'
15
+ require 'shipvine/item_group'
16
+ require 'shipvine/deep_compact'
17
+
18
+ module Shipvine
19
+ class << self
20
+ attr_accessor :api_key, :merchant_code, :validate_xml, :testmode
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ module Shipvine
2
+ class Base
3
+ WAREHOUSE_XSD_FILE = File.expand_path('../../../data/WarehouseFS-1.0.xsd', __FILE__)
4
+
5
+ def self.client
6
+ @client ||= Shipvine::Client.new
7
+ end
8
+
9
+ def self.xml_to_hash(raw_xml)
10
+ Nori.new(:convert_tags_to => lambda { |tag| tag.snakecase.to_sym })
11
+ .parse(raw_xml)
12
+ end
13
+
14
+ protected
15
+
16
+ def client
17
+ self.class.client
18
+ end
19
+
20
+ def add_xml_sequence(hash, order_array)
21
+ hash[:order!] = order_array.select { |field| hash.keys.include?(field) }
22
+ end
23
+
24
+ def add_sequence_annotations(root_hash)
25
+ puts "ShipVine: add_sequence_annotations not defined for #{self.class}"
26
+ end
27
+
28
+ def request_body(resource_name, attributes_hash)
29
+ sanitized_hash = build_sanitized_hash(resource_name, attributes_hash)
30
+
31
+ add_sequence_annotations(sanitized_hash[resource_name])
32
+
33
+ request_xml = Gyoku.xml(sanitized_hash, {
34
+ :key_converter => :camelcase
35
+ })
36
+
37
+ if Shipvine.validate_xml.nil? || Shipvine.validate_xml
38
+ validate_xml!(request_xml)
39
+ end
40
+
41
+ request_xml
42
+ end
43
+
44
+ def build_sanitized_hash(resource_name, hash)
45
+ # ShipVine doesn't accept `xsi:nil=true` attributes; these must be stripped
46
+ # before performing hash => XML conversion
47
+ sanitized = hash.deep_compact!
48
+
49
+ {
50
+ resource_name => {
51
+ :@xmlns => 'urn:WarehouseFS-1.0',
52
+ :merchant_code => Shipvine.merchant_code
53
+ }.merge(sanitized)
54
+ }
55
+ end
56
+
57
+ def validate_xml!(xml)
58
+ xsd = Nokogiri::XML::Schema(File.read(WAREHOUSE_XSD_FILE))
59
+ doc = Nokogiri::XML(xml)
60
+
61
+ errors = xsd.validate(doc)
62
+
63
+ if !errors.empty?
64
+ puts xml
65
+ raise "Error validating XML: #{errors.join("\n")}"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,21 @@
1
+ module Shipvine
2
+ class BillOfLading < Base
3
+
4
+ attr_accessor :formatted, :numeric
5
+
6
+ def self.generate
7
+ response = self.client.request(
8
+ :post,
9
+ '/bol-numbers/' + Shipvine.merchant_code,
10
+ ''
11
+ )
12
+
13
+ lading = self.new
14
+ lading.formatted = response["BillOfLadingNumber"]["Formatted"]
15
+ lading.numeric = response["BillOfLadingNumber"]["Numeric"]
16
+
17
+ lading
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,78 @@
1
+ module Shipvine
2
+ class Client
3
+ include HTTParty
4
+ base_uri 'https://api.warehousefs.com/public/v1/'
5
+
6
+ def initialize(opts = {})
7
+ @options = opts
8
+ end
9
+
10
+ def request(method, resource, params = {}, exclude_merchant_code: false)
11
+ options = default_options.deep_merge(@options)
12
+
13
+ options = if method == :get
14
+ options.merge(query:
15
+ sanitize_query_parameters(params, exclude_merchant_code: exclude_merchant_code)
16
+ )
17
+ else
18
+ # TODO a sanitization method is needed here
19
+ options.merge(body: params)
20
+ end
21
+
22
+ response = self.class.send(method, resource, options)
23
+
24
+ # NOTE response will be nil if the body of the response is nil
25
+
26
+ if response.code != 200
27
+ # TODO response.response.message == 'Unauthorized' in the case of 401
28
+ # possibly try to sniff out more descriptive error information
29
+
30
+ fail Shipvine::Error.new(response), "#{response.code} #{response.body}"
31
+ else
32
+ response
33
+ end
34
+ end
35
+
36
+ protected
37
+
38
+ def sanitize_query_parameters(params, exclude_merchant_code: false)
39
+ params = params.dup
40
+
41
+ # TODO dynamic for input params hyphen/kebab case conversion
42
+ # TODO stop hard coding shipvine parameter possibilities here
43
+
44
+ params.each do |k, v|
45
+ # force iso8601 on date/time
46
+ if %w(created-since changed-since).include?(k) && [ DateTime, Date, Time ].include?(v.class)
47
+ params[k] = v.iso8601
48
+ end
49
+ end
50
+
51
+ # NOTE some endpoints include the merchant code in the URL
52
+
53
+ if exclude_merchant_code
54
+ params
55
+ else
56
+ { 'merchant-code' => Shipvine.merchant_code }.merge(params)
57
+ end
58
+ end
59
+
60
+ def default_options
61
+ opts = {
62
+ headers: {
63
+ 'X-Api-Key' => Shipvine.api_key
64
+ }
65
+ }
66
+
67
+ if Shipvine.testmode
68
+ opts[:headers]['X-Api-Test'] = 'true'
69
+ end
70
+
71
+ opts
72
+ end
73
+
74
+ def sanitize_body_parameters
75
+ # ruby has to xml
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,23 @@
1
+ # https://github.com/infochimps-labs/gorillib/blob/cd88bed1cba29c31f27dcce90bddbcbfbca33487/lib/gorillib/array/deep_compact.rb
2
+
3
+ class Hash
4
+ def deep_compact!
5
+ each_pair do |key, val|
6
+ val.deep_compact! if val.respond_to?(:deep_compact!)
7
+ delete(key) if val.blank?
8
+ end
9
+ self
10
+ end
11
+ end
12
+
13
+ class Array
14
+ #
15
+ # deep_compact! removes all 'blank?' elements in the array in place, recursively
16
+ #
17
+ def deep_compact!
18
+ self.map! do |val|
19
+ val.deep_compact! if val.respond_to?(:deep_compact!)
20
+ val unless val.blank?
21
+ end.compact!
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ module Shipvine
2
+ class Error < StandardError
3
+ attr_reader :code, :shipvine_message
4
+
5
+ def initialize(response = nil)
6
+ if response.present?
7
+ @code = response.code
8
+ @shipvine_message = response.parsed_response["Errors"]["Error"]["Message"]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,243 @@
1
+ module Shipvine
2
+ class FulfillmentRequest < Base
3
+ STATIC_REQUEST_ELEMENTS = {
4
+ currency: 'USD',
5
+ discount: {
6
+ amount: 0.0,
7
+ currency: 'USD'
8
+ },
9
+ tax: {
10
+ amount: 0.0,
11
+ currency: 'USD'
12
+ },
13
+ shipping: {
14
+ amount: 0.0,
15
+ currency: 'USD'
16
+ }
17
+ }.freeze
18
+
19
+ # TODO split classes into FulfillmentRequestSubmission and FulfillmentRequest?
20
+
21
+ def self.get_shipvine_identifier(merchant_identifier)
22
+ request = self.client.request(
23
+ :get,
24
+ '/fulfillment-requests',
25
+ {
26
+ "merchant-identifier" => merchant_identifier
27
+ }
28
+ )
29
+
30
+ request["FulfillmentRequests"]["FulfillmentRequest"]["ShipvineIdentifier"]
31
+ rescue Shipvine::Error => e
32
+ # NOTE SV will return a 404, or other non-200 response if the fulfillment does not exist
33
+ nil
34
+ end
35
+
36
+ def self.get(shipvine_identifier)
37
+ request = self.client.request(
38
+ :get,
39
+ '/fulfillment-requests/' + shipvine_identifier
40
+ )
41
+
42
+ self.new(self.xml_to_hash(request.body))
43
+ end
44
+
45
+ attr_reader :attributes
46
+
47
+ def initialize(attributes)
48
+ @attributes = attributes
49
+ end
50
+
51
+ def metadata
52
+ metadata_entries = self.attributes.dig(:fulfillment_request, :metadata, :metadata)
53
+
54
+ if metadata_entries.nil? || metadata_entries.empty?
55
+ return {}
56
+ end
57
+
58
+ # TODO cache the resulting hash here
59
+
60
+ metadata_entries = Array.wrap(metadata_entries)
61
+
62
+ metadata_entries.inject({}) do |h, sv_metadata_entry|
63
+ h[sv_metadata_entry[:name]] = sv_metadata_entry[:value]
64
+ h
65
+ end
66
+ end
67
+
68
+ def create
69
+ translated_payload = @attributes.deep_dup
70
+
71
+ preprocess_lines(translated_payload)
72
+ preprocess_address(translated_payload)
73
+ preprocess_metadata(translated_payload)
74
+ add_static_elements(translated_payload)
75
+
76
+ client.request(
77
+ :post,
78
+ '/fulfillment-request-submissions',
79
+ request_body('FulfillmentRequestSubmission', translated_payload)
80
+ )
81
+ end
82
+
83
+ protected
84
+
85
+ def add_static_elements(order_hash)
86
+ order_hash.merge!(STATIC_REQUEST_ELEMENTS)
87
+ end
88
+
89
+ def preprocess_metadata(order_hash)
90
+ metadata = order_hash.delete(:metadata)
91
+
92
+ if metadata
93
+ order_hash[:request_metadata] = {
94
+ :metadata => metadata.map do |k, v|
95
+ {
96
+ name: k,
97
+ value: v
98
+ }
99
+ end
100
+ }
101
+ end
102
+ end
103
+
104
+ def preprocess_lines(order_hash)
105
+ items = order_hash.delete(:lines)
106
+
107
+ order_hash[:lines] = {
108
+ :line => items.map do |item|
109
+ h = {
110
+ item: {
111
+ # TODO support other item keys, `sku`, `id`, etc
112
+ merchant_identifier: item[:product_id]
113
+ },
114
+ quantity: item[:quantity],
115
+ unit_price: {
116
+ amount: 0.0,
117
+ currency: 'USD'
118
+ }
119
+ }
120
+
121
+ if item[:stored_value]
122
+ h[:item][:stored_value] = {
123
+ amount: item[:stored_value],
124
+ currency: 'USD'
125
+ }
126
+ end
127
+
128
+ if item[:gift_box]
129
+ h[:item][:gift_box] = true
130
+ end
131
+
132
+ if item[:modifications]
133
+ h[:item][:modifications] = {
134
+ modification: item[:modifications]
135
+ }
136
+ end
137
+
138
+ h
139
+ end
140
+ }
141
+
142
+ order_hash
143
+ end
144
+
145
+ def preprocess_address(order_hash)
146
+ order_hash[:address][:@type] = "Shipping"
147
+ order_hash[:address][:country] ||= 'US'
148
+
149
+ address1 = order_hash[:address].delete(:address1)
150
+ address2 = order_hash[:address].delete(:address2)
151
+
152
+ order_hash[:address][:street_lines] = {
153
+ :street_line => [
154
+ address1,
155
+ address2
156
+ ]
157
+ }
158
+
159
+ order_hash[:address][:personal_name] = {
160
+ first: order_hash.delete(:first_name),
161
+ last: order_hash.delete(:last_name)
162
+ }
163
+
164
+ # shipvine requires first and last; pull from company field if not defined
165
+ if order_hash[:address][:personal_name][:first].blank? || order_hash[:address][:personal_name][:last].blank?
166
+ if order_hash[:address][:company].nil? || order_hash[:address][:company].empty?
167
+ fail "both name and company fields are empty, cannot create fulfillment"
168
+ end
169
+
170
+ first_name, last_name = order_hash[:address][:company].split(' ', 2)
171
+
172
+ # NOTE first and last name are always required in ShipVine
173
+ # ensure that *some* value is always set
174
+
175
+ if last_name.nil?
176
+ last_name = first_name
177
+ end
178
+
179
+ if first_name.nil?
180
+ first_name = last_name
181
+ end
182
+
183
+ order_hash[:address][:personal_name][:first] ||= first_name
184
+ order_hash[:address][:personal_name][:last] ||= last_name
185
+ end
186
+
187
+ order_hash
188
+ end
189
+
190
+ def add_sequence_annotations(hash)
191
+ add_xml_sequence(hash, %i(
192
+ merchant_code
193
+ merchant_identifier
194
+ customer_identifier
195
+ bill_of_lading_number
196
+ currency
197
+ email_address
198
+ address
199
+ lines
200
+ requested_documents
201
+ merchant_return_profile
202
+ ucc_label_template
203
+ packing_slip_template
204
+ shipping_method
205
+ shipping_charges_payer
206
+ return_label
207
+ discount
208
+ tax
209
+ shipping
210
+ request_metadata
211
+ ))
212
+
213
+ add_xml_sequence(hash[:address], %i(
214
+ country
215
+ personal_name
216
+ company
217
+ street_lines
218
+ city_or_town
219
+ state_or_province
220
+ postal_code
221
+ phone
222
+ ))
223
+
224
+ hash[:lines][:line].each do |line|
225
+ add_xml_sequence(line, %i(
226
+ item
227
+ quantity
228
+ unit_price
229
+ ))
230
+
231
+ add_xml_sequence(line[:item], %i(
232
+ merchant_identifier
233
+ modifications
234
+ stored_value
235
+ gift_box
236
+ ))
237
+ end
238
+
239
+ # TODO specify first and last order under `personal_name`
240
+ end
241
+
242
+ end
243
+ end