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.
Files changed (42) hide show
  1. data/Gemfile +13 -0
  2. data/Gemfile.lock +80 -0
  3. data/Guardfile +9 -0
  4. data/HACKING +31 -0
  5. data/LICENSE.txt +7 -0
  6. data/README.rdoc +191 -0
  7. data/Rakefile +45 -0
  8. data/VERSION +1 -0
  9. data/lib/ns_connector.rb +4 -0
  10. data/lib/ns_connector/attaching.rb +42 -0
  11. data/lib/ns_connector/chunked_searching.rb +111 -0
  12. data/lib/ns_connector/config.rb +66 -0
  13. data/lib/ns_connector/errors.rb +79 -0
  14. data/lib/ns_connector/field_store.rb +19 -0
  15. data/lib/ns_connector/hash.rb +11 -0
  16. data/lib/ns_connector/resource.rb +288 -0
  17. data/lib/ns_connector/resources.rb +3 -0
  18. data/lib/ns_connector/resources/contact.rb +279 -0
  19. data/lib/ns_connector/resources/customer.rb +355 -0
  20. data/lib/ns_connector/resources/invoice.rb +466 -0
  21. data/lib/ns_connector/restlet.rb +137 -0
  22. data/lib/ns_connector/sublist.rb +21 -0
  23. data/lib/ns_connector/sublist_item.rb +25 -0
  24. data/misc/failed_sublist_saving_patch +547 -0
  25. data/scripts/run_restlet +25 -0
  26. data/scripts/test_shell +21 -0
  27. data/spec/attaching_spec.rb +48 -0
  28. data/spec/chunked_searching_spec.rb +75 -0
  29. data/spec/config_spec.rb +43 -0
  30. data/spec/resource_spec.rb +340 -0
  31. data/spec/resources/contact_spec.rb +8 -0
  32. data/spec/resources/customer_spec.rb +8 -0
  33. data/spec/resources/invoice_spec.rb +20 -0
  34. data/spec/restlet_spec.rb +135 -0
  35. data/spec/spec_helper.rb +16 -0
  36. data/spec/sublist_item_spec.rb +25 -0
  37. data/spec/sublist_spec.rb +45 -0
  38. data/spec/support/mock_data.rb +10 -0
  39. data/support/read_only_test +63 -0
  40. data/support/restlet.js +384 -0
  41. data/support/super_dangerous_write_test +85 -0
  42. 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