ns_connector 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- 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
|