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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +2 -0
- data/README.md +104 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/data/WarehouseFS-1.0.xsd +603 -0
- data/lib/shipvine.rb +22 -0
- data/lib/shipvine/base.rb +69 -0
- data/lib/shipvine/bill_of_lading.rb +21 -0
- data/lib/shipvine/client.rb +78 -0
- data/lib/shipvine/deep_compact.rb +23 -0
- data/lib/shipvine/error.rb +12 -0
- data/lib/shipvine/fulfillment_request.rb +243 -0
- data/lib/shipvine/inbound_shipment.rb +129 -0
- data/lib/shipvine/item.rb +74 -0
- data/lib/shipvine/item_group.rb +53 -0
- data/lib/shipvine/outbound_shipment.rb +30 -0
- data/lib/shipvine/version.rb +3 -0
- data/shipvine.gemspec +28 -0
- metadata +147 -0
data/lib/shipvine.rb
ADDED
@@ -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
|