ns_connector 0.0.6
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.
- data/Gemfile +13 -0
- data/Gemfile.lock +80 -0
- data/Guardfile +9 -0
- data/HACKING +31 -0
- data/LICENSE.txt +7 -0
- data/README.rdoc +191 -0
- data/Rakefile +45 -0
- data/VERSION +1 -0
- data/lib/ns_connector.rb +4 -0
- data/lib/ns_connector/attaching.rb +42 -0
- data/lib/ns_connector/chunked_searching.rb +111 -0
- data/lib/ns_connector/config.rb +66 -0
- data/lib/ns_connector/errors.rb +79 -0
- data/lib/ns_connector/field_store.rb +19 -0
- data/lib/ns_connector/hash.rb +11 -0
- data/lib/ns_connector/resource.rb +288 -0
- data/lib/ns_connector/resources.rb +3 -0
- data/lib/ns_connector/resources/contact.rb +279 -0
- data/lib/ns_connector/resources/customer.rb +355 -0
- data/lib/ns_connector/resources/invoice.rb +466 -0
- data/lib/ns_connector/restlet.rb +137 -0
- data/lib/ns_connector/sublist.rb +21 -0
- data/lib/ns_connector/sublist_item.rb +25 -0
- data/misc/failed_sublist_saving_patch +547 -0
- data/scripts/run_restlet +25 -0
- data/scripts/test_shell +21 -0
- data/spec/attaching_spec.rb +48 -0
- data/spec/chunked_searching_spec.rb +75 -0
- data/spec/config_spec.rb +43 -0
- data/spec/resource_spec.rb +340 -0
- data/spec/resources/contact_spec.rb +8 -0
- data/spec/resources/customer_spec.rb +8 -0
- data/spec/resources/invoice_spec.rb +20 -0
- data/spec/restlet_spec.rb +135 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/sublist_item_spec.rb +25 -0
- data/spec/sublist_spec.rb +45 -0
- data/spec/support/mock_data.rb +10 -0
- data/support/read_only_test +63 -0
- data/support/restlet.js +384 -0
- data/support/super_dangerous_write_test +85 -0
- metadata +221 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
# A 'global' config.
|
2
|
+
#
|
3
|
+
# Being global, we are restricted to connecting to one NetSuite upstream URL
|
4
|
+
# per ruby process.
|
5
|
+
class NSConnector::Config
|
6
|
+
@@options = {}
|
7
|
+
ArgumentError = Class.new(Exception)
|
8
|
+
DEFAULT = {
|
9
|
+
:use_threads => true,
|
10
|
+
:no_threads => 4
|
11
|
+
}
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Read a key stored in @@options: Config[:key]
|
15
|
+
def [](key)
|
16
|
+
key = key.to_sym
|
17
|
+
val = @@options[key]
|
18
|
+
|
19
|
+
val.nil? ? DEFAULT[key] : val
|
20
|
+
end
|
21
|
+
|
22
|
+
# Write a key stored in @@options: Config[:key] = 1
|
23
|
+
def []=(key, value)
|
24
|
+
@@options[key.to_sym] = value
|
25
|
+
end
|
26
|
+
|
27
|
+
# Overwrite the current 'global' config with +options+
|
28
|
+
def set_config! options
|
29
|
+
@@options = {}
|
30
|
+
options.each do |k,v|
|
31
|
+
@@options[k.to_sym] = v
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check if the current config is valid.
|
36
|
+
# Returns:
|
37
|
+
# true:: if all required keys are supplied
|
38
|
+
# Raises:
|
39
|
+
# ArgumentError:: if any keys are missing
|
40
|
+
def check_valid!
|
41
|
+
unless @@options then
|
42
|
+
raise NSConnector::Config::ArgumentError,
|
43
|
+
'Need a configuration set. '\
|
44
|
+
'See: NSConnector::Config.set_config!'
|
45
|
+
end
|
46
|
+
|
47
|
+
required = [
|
48
|
+
:account_id,
|
49
|
+
:email,
|
50
|
+
:password,
|
51
|
+
:role,
|
52
|
+
:restlet_url
|
53
|
+
]
|
54
|
+
|
55
|
+
missing_keys = (required - @@options.keys)
|
56
|
+
unless missing_keys.empty?
|
57
|
+
raise NSConnector::Config::ArgumentError,
|
58
|
+
'Missing configuration key(s): '\
|
59
|
+
"#{missing_keys.join(', ')}"
|
60
|
+
end
|
61
|
+
|
62
|
+
# All good
|
63
|
+
return true
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# A collection of useful, catchable, and usually netsuite related errors.
|
2
|
+
#
|
3
|
+
# For a list of errors that can be returned and conditions for returning said
|
4
|
+
# errors, see #try_handle_response!
|
5
|
+
module NSConnector::Errors
|
6
|
+
# Parent class to encapsulate all successfully parsed JSON netsuite
|
7
|
+
# error responses.
|
8
|
+
class NSError < Exception
|
9
|
+
def initialize netsuite_error
|
10
|
+
@netsuite_error = netsuite_error
|
11
|
+
end
|
12
|
+
|
13
|
+
# Returns the error code from the JSON returned by netsuite.
|
14
|
+
# Something like: "RCRD_DSNT_EXIST"
|
15
|
+
def code
|
16
|
+
@netsuite_error['code']
|
17
|
+
end
|
18
|
+
|
19
|
+
# Takes the error message straight out of the netsuite
|
20
|
+
# response. Usually makes some sense.
|
21
|
+
def message
|
22
|
+
@netsuite_error['message']
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Not found
|
27
|
+
NotFound = Class.new(NSError)
|
28
|
+
# Some field has a unique constraint on it which has a duplicate
|
29
|
+
Conflict = Class.new(NSError)
|
30
|
+
# Usually a search run on an invalid field
|
31
|
+
InvalidSearchFilter = Class.new(NSError)
|
32
|
+
|
33
|
+
# Internal use
|
34
|
+
BeginChunking = Class.new(NSError)
|
35
|
+
# Internal use
|
36
|
+
EndChunking = Class.new(NSError)
|
37
|
+
|
38
|
+
# Unknown errors should still have a #code and #message that is useful.
|
39
|
+
# They are raised when we got a JSON error response from NetSuite that
|
40
|
+
# we simply don't cater explicitly for.
|
41
|
+
Unknown = Class.new(NSError)
|
42
|
+
|
43
|
+
# Complete garbage received
|
44
|
+
WTF = Class.new(RuntimeError)
|
45
|
+
|
46
|
+
# Try and make a HTTP response from netsuite a nice error.
|
47
|
+
# Arguments:: A Net::HTTP response, should be a 400
|
48
|
+
# Raises::
|
49
|
+
# NSConnector::Errors::NotFound:: on a RCRD_DSNT_EXIST
|
50
|
+
# NSConnector::Errors::InvalidSearchFilter:: on a
|
51
|
+
# SSS_INVALID_SRCH_FILTER
|
52
|
+
# NSConnector::Errors::Conflict:: on a *_ALREADY_EXISTS
|
53
|
+
# NSConnector::Errors::Unknown:: on any unhandled but parseable error
|
54
|
+
# NSConnector::Errors::WTF:: on complete garbage from netsuite
|
55
|
+
#
|
56
|
+
# Returns:: Shouldn't return
|
57
|
+
def self.try_handle_response! response
|
58
|
+
error = JSON.parse(response.body)['error']
|
59
|
+
case error['code']
|
60
|
+
when 'RCRD_DSNT_EXIST'
|
61
|
+
raise NotFound, error
|
62
|
+
when 'SSS_INVALID_SRCH_FILTER'
|
63
|
+
raise InvalidSearchFilter, error
|
64
|
+
when /_ALREADY_EXISTS$/
|
65
|
+
raise Conflict, error
|
66
|
+
else
|
67
|
+
case error['message']
|
68
|
+
when 'CHUNKY_MONKEY'
|
69
|
+
raise BeginChunking, error
|
70
|
+
when 'NO_MORE_CHUNKS'
|
71
|
+
raise EndChunking, error
|
72
|
+
end
|
73
|
+
raise Unknown, error
|
74
|
+
end
|
75
|
+
rescue JSON::ParserError
|
76
|
+
raise WTF, 'Unparseable response, expecting JSON. '\
|
77
|
+
"HTTP #{response.code}: \n#{response.body}"
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# Provides a method create_store_store_accessors! to make keys fields
|
2
|
+
# accessible in @store
|
3
|
+
module NSConnector::FieldStore
|
4
|
+
# Given fields of ['name'], we want to define a name= and a name
|
5
|
+
# method to retrieve and set the key 'name' in our @store
|
6
|
+
def create_store_accessors!
|
7
|
+
fields.each do |field|
|
8
|
+
self.class.class_eval do
|
9
|
+
define_method field do
|
10
|
+
@store[field.to_s]
|
11
|
+
end
|
12
|
+
|
13
|
+
define_method "#{field}=" do |value|
|
14
|
+
@store[field.to_s] = value
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class Hash # :nodoc:
|
2
|
+
# Turn all our keys into strings. Good for our @stores as they use
|
3
|
+
# strings from keys seeing as anything coming from JSON has strings for
|
4
|
+
# keys
|
5
|
+
def stringify_keys!
|
6
|
+
keys.each do |key|
|
7
|
+
self[key.to_s] = delete(key)
|
8
|
+
end
|
9
|
+
self
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,288 @@
|
|
1
|
+
require 'ns_connector/restlet'
|
2
|
+
require 'ns_connector/errors'
|
3
|
+
require 'ns_connector/chunked_searching'
|
4
|
+
require 'ns_connector/field_store'
|
5
|
+
require 'ns_connector/hash'
|
6
|
+
require 'ns_connector/sublist'
|
7
|
+
require 'ns_connector/sublist_item'
|
8
|
+
require 'ns_connector/attaching'
|
9
|
+
|
10
|
+
# This is a 'meta' class that all our useful NetSuite classes inherit from,
|
11
|
+
# overriding what they may need to. For example:
|
12
|
+
# class Contact < Resource
|
13
|
+
# # The NetSuite internal id for the object.
|
14
|
+
# @type_id = 'contact'
|
15
|
+
# end
|
16
|
+
class NSConnector::Resource
|
17
|
+
include NSConnector::FieldStore
|
18
|
+
extend NSConnector::ChunkedSearching
|
19
|
+
extend NSConnector::Attaching
|
20
|
+
|
21
|
+
attr_accessor :store
|
22
|
+
|
23
|
+
def initialize upstream_store=nil, in_netsuite=false
|
24
|
+
upstream_store.stringify_keys! if upstream_store
|
25
|
+
|
26
|
+
@store = (upstream_store || {})
|
27
|
+
@sublist_store = {}
|
28
|
+
|
29
|
+
# This is set so that we can tell wether we need to create an
|
30
|
+
# entirely new netstuite object, or we are modifying an
|
31
|
+
# existing one.
|
32
|
+
@in_netsuite = in_netsuite
|
33
|
+
|
34
|
+
check_id10t_errors!
|
35
|
+
create_store_accessors!
|
36
|
+
create_sublist_accessors!
|
37
|
+
end
|
38
|
+
|
39
|
+
# Just so I don't forget to define certain things.
|
40
|
+
def check_id10t_errors!
|
41
|
+
unless fields then
|
42
|
+
raise ::ArgumentError,
|
43
|
+
"Inherited class #{self.class} needs to "\
|
44
|
+
"define @fields class instance variable"
|
45
|
+
end
|
46
|
+
# Type doesn't matter
|
47
|
+
unless fields.include? 'id' or fields.include? :id
|
48
|
+
raise ::ArgumentError,
|
49
|
+
"Inherited class #{self.class} must define "\
|
50
|
+
"an 'id' field"
|
51
|
+
end
|
52
|
+
unless type_id then
|
53
|
+
raise ::ArgumentError,
|
54
|
+
"Inherited class #{self.class} needs to "\
|
55
|
+
"define @type_id class instance variable"
|
56
|
+
end
|
57
|
+
unless sublists then
|
58
|
+
raise ::ArgumentError,
|
59
|
+
"Inherited class #{self.class} needs to "\
|
60
|
+
"define @sublists class instance variable"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Retrieve class's internal id, e.g. 'contact' for a Contact Resource
|
65
|
+
def type_id
|
66
|
+
self.class.type_id
|
67
|
+
end
|
68
|
+
|
69
|
+
# List of all fields for class
|
70
|
+
def fields
|
71
|
+
self.class.fields
|
72
|
+
end
|
73
|
+
|
74
|
+
# List of all sublists for class
|
75
|
+
def sublists
|
76
|
+
self.class.sublists
|
77
|
+
end
|
78
|
+
|
79
|
+
# Attach ids on target klass to this record
|
80
|
+
# Arguments::
|
81
|
+
# klass:: Target class to attach to, e.g. Contact
|
82
|
+
# ids:: Array of ids to attach
|
83
|
+
# attributes:: Optional attributes for attach, e.g. {:role => -5}
|
84
|
+
# Example::
|
85
|
+
# contact.attach!(Customer, [1198], {:role => 1})
|
86
|
+
def attach!(klass, ids, attributes=nil)
|
87
|
+
raise ::ArgumentError, 'Need an id to attach!' unless id
|
88
|
+
self.class.attach!(klass, id, ids, attributes)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Detach ids on target klass to this record
|
92
|
+
# Arguments::
|
93
|
+
# klass:: Target class to detach from, i.e. Contact
|
94
|
+
# ids:: Array of ids to detach
|
95
|
+
def detach!(klass, ids)
|
96
|
+
raise ::ArgumentError, 'Need an id to detach!' unless id
|
97
|
+
self.class.detach!(klass, id, ids)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Format an object like: '#<NSConnector::PseudoResource:1>'
|
101
|
+
def inspect
|
102
|
+
"#<NSConnector::#{self.class}:#{id.inspect}>"
|
103
|
+
end
|
104
|
+
|
105
|
+
# Is this resource already in NetSuite?
|
106
|
+
# Returns::
|
107
|
+
# true:: if this resource has been retrieved from netsuite,
|
108
|
+
# false:: if it is a new resource being created for the first time.
|
109
|
+
def in_netsuite?
|
110
|
+
@in_netsuite
|
111
|
+
end
|
112
|
+
|
113
|
+
# Save ourself to NetSuite.
|
114
|
+
#
|
115
|
+
# Raises:: NSConnector::Errors various errors if something explodes
|
116
|
+
# Returns:: true
|
117
|
+
def save!
|
118
|
+
@store = NSConnector::Restlet.execute!(
|
119
|
+
:action => in_netsuite? ? 'update' : 'create',
|
120
|
+
:type_id => type_id,
|
121
|
+
:fields => fields,
|
122
|
+
:data => @store
|
123
|
+
)
|
124
|
+
# If we got this far, we're definitely in NetSuite
|
125
|
+
@in_netsuite = true
|
126
|
+
|
127
|
+
return true
|
128
|
+
end
|
129
|
+
|
130
|
+
# Delete ourself from NetSuite
|
131
|
+
#
|
132
|
+
# Returns::
|
133
|
+
# true:: If object deleted
|
134
|
+
# false:: If object was not deleted as it never existed
|
135
|
+
def delete!
|
136
|
+
return false unless in_netsuite?
|
137
|
+
fail 'Sanity check: resource should have an ID' unless id
|
138
|
+
self.class.delete!(id)
|
139
|
+
|
140
|
+
# We set our :id to nil as we don't have one anymore and it
|
141
|
+
# allows us to call save on our newly deleted record, in case
|
142
|
+
# we wanted to undelete or something crazy like that.
|
143
|
+
@store[:id] = nil
|
144
|
+
@in_netsuite = false
|
145
|
+
|
146
|
+
return true
|
147
|
+
end
|
148
|
+
|
149
|
+
class << self
|
150
|
+
# Provides accessibility to class instance variables
|
151
|
+
attr_reader :type_id
|
152
|
+
attr_reader :fields
|
153
|
+
attr_reader :sublists
|
154
|
+
|
155
|
+
# Delete a single ID from NetSuite
|
156
|
+
#
|
157
|
+
# Returns:: Nothing useful
|
158
|
+
# Raises:: Relevant exceptions on failure
|
159
|
+
def delete! id
|
160
|
+
NSConnector::Restlet.execute!(
|
161
|
+
:action => 'delete',
|
162
|
+
:type_id => type_id,
|
163
|
+
:data => {'id' => Integer(id)}
|
164
|
+
)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Retrieve a single resource from NetSuite with +id+
|
168
|
+
def find id
|
169
|
+
self.new(
|
170
|
+
NSConnector::Restlet.execute!(
|
171
|
+
:action => 'retrieve',
|
172
|
+
:type_id => type_id,
|
173
|
+
:fields => fields,
|
174
|
+
:data => {'id' => Integer(id)}
|
175
|
+
),
|
176
|
+
true
|
177
|
+
)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Retrieve all records, will most likely become a chunked
|
181
|
+
# search due to size
|
182
|
+
def all
|
183
|
+
advanced_search([])
|
184
|
+
end
|
185
|
+
|
186
|
+
# Perform a search by field, with value matching exactly
|
187
|
+
def search_by field, value
|
188
|
+
advanced_search([[field, nil, 'is', value]])
|
189
|
+
end
|
190
|
+
|
191
|
+
# Perform a flexible search. It is assumed you kind of know
|
192
|
+
# what you're doing here and create a filter (a SuiteScript
|
193
|
+
# nlobjSearchFilter)
|
194
|
+
# Example::
|
195
|
+
# Resource.advanced_search([
|
196
|
+
# ['type_id', nil, 'greaterthan', 1000],
|
197
|
+
# ['email', nil, 'contains', '@'],
|
198
|
+
# [...]
|
199
|
+
# ])
|
200
|
+
# Arguments::
|
201
|
+
# +filters+:: An array of netsuite 'filters' see: +Filters+
|
202
|
+
#
|
203
|
+
# Filters::
|
204
|
+
# A filter is simply an array that is sent as arguments to
|
205
|
+
# the netsuite function +nlobjSearchFilter+
|
206
|
+
#
|
207
|
+
# It often takes the form of:
|
208
|
+
# [field, join record type or nil, operator, value]
|
209
|
+
#
|
210
|
+
# i.e:
|
211
|
+
# ['internalid', nil, 'is', customer_id]
|
212
|
+
#
|
213
|
+
# Returns::
|
214
|
+
# An array of +Resources+
|
215
|
+
def advanced_search filters
|
216
|
+
unless filters.is_a? Array
|
217
|
+
raise ::ArgumentError,
|
218
|
+
'Expected an Array of filters'
|
219
|
+
end
|
220
|
+
|
221
|
+
return NSConnector::Restlet.execute!(
|
222
|
+
:action => 'search',
|
223
|
+
:type_id => type_id,
|
224
|
+
:fields => fields,
|
225
|
+
:data => {:filters => filters}
|
226
|
+
).map do |upstream_store|
|
227
|
+
self.new(upstream_store, true)
|
228
|
+
end
|
229
|
+
rescue NSConnector::Errors::BeginChunking
|
230
|
+
# Result set is too large, we have to ask for
|
231
|
+
# it in offsets. Note that if the result set
|
232
|
+
# changes between requests say, at the
|
233
|
+
# beginning, we are going to get odd behaviour.
|
234
|
+
# Better than nothing, though.
|
235
|
+
#
|
236
|
+
# For this function, see:
|
237
|
+
# ns_connector/chunked_searching.rb
|
238
|
+
return search_by_chunks(filters)
|
239
|
+
end
|
240
|
+
|
241
|
+
# Quicker and more flexible than a normal search as it doesn't
|
242
|
+
# return whole objects, just the search columns specified as an
|
243
|
+
# array of arrays.
|
244
|
+
#
|
245
|
+
# Arguments::
|
246
|
+
# columns:: Array of requested colums, e.g.:
|
247
|
+
# [['role'], ['entityId', 'customer']]
|
248
|
+
# filters:: Array of filters, same as #advanced_search, e.g.:
|
249
|
+
# [['entityId', 'customer', 'is', '296']]
|
250
|
+
# Returns:: Array of result columns, in an array. So an array
|
251
|
+
# of arrays.
|
252
|
+
def raw_search columns, filters
|
253
|
+
return NSConnector::Restlet.execute!(
|
254
|
+
:action => 'raw_search',
|
255
|
+
:type_id => type_id,
|
256
|
+
:fields => fields,
|
257
|
+
:data => {
|
258
|
+
:columns => columns,
|
259
|
+
:filters => filters
|
260
|
+
}
|
261
|
+
)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
private
|
266
|
+
# Given a sublist of {:addressbook => ['fields']} we want a method
|
267
|
+
# addressbook that looks up the sublist if we have an ID, otherwise
|
268
|
+
# returns an empty array.
|
269
|
+
def create_sublist_accessors!
|
270
|
+
sublists.each do |sublist_name, fields|
|
271
|
+
self.class.class_eval do
|
272
|
+
define_method sublist_name do
|
273
|
+
# We are an object in netsuite,
|
274
|
+
# we might just have sublist
|
275
|
+
# items already. So we check.
|
276
|
+
@sublist_store[sublist_name] ||= \
|
277
|
+
NSConnector::SubList.fetch(
|
278
|
+
self,
|
279
|
+
sublist_name,
|
280
|
+
fields
|
281
|
+
) if in_netsuite?
|
282
|
+
|
283
|
+
@sublist_store[sublist_name] ||= []
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|