ns_connector 0.0.6

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