shipvine 0.1.0

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.
@@ -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