ruby_odata 0.1.0 → 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/.coveralls.yml +1 -0
  3. data/.gitignore +7 -2
  4. data/.simplecov +10 -0
  5. data/.travis.yml +10 -4
  6. data/.yardopts +6 -0
  7. data/CHANGELOG.md +157 -0
  8. data/Guardfile +14 -0
  9. data/LICENSE +20 -22
  10. data/README.md +289 -0
  11. data/Rakefile +19 -9
  12. data/features/basic_auth.feature +3 -2
  13. data/features/batch_request.feature +7 -6
  14. data/features/cassettes/basic_auth_protected_resource.yml +57 -0
  15. data/features/cassettes/batch_request_additions.yml +69 -0
  16. data/features/cassettes/batch_request_deletes.yml +69 -0
  17. data/features/cassettes/batch_request_updates.yml +69 -0
  18. data/features/cassettes/clean_database_for_testing.yml +46 -0
  19. data/features/cassettes/cucumber_tags/basic_auth.yml +297 -0
  20. data/features/cassettes/cucumber_tags/batch_request.yml +1459 -0
  21. data/features/cassettes/cucumber_tags/complex_types.yml +326 -0
  22. data/features/cassettes/cucumber_tags/error_handling.yml +64 -0
  23. data/features/cassettes/cucumber_tags/query_builder.yml +2025 -0
  24. data/features/cassettes/cucumber_tags/service.yml +234 -0
  25. data/features/cassettes/cucumber_tags/service_manage.yml +937 -0
  26. data/features/cassettes/cucumber_tags/service_methods.yml +647 -0
  27. data/features/cassettes/cucumber_tags/ssl.yml +203 -0
  28. data/features/cassettes/cucumber_tags/type_conversion.yml +337 -0
  29. data/features/cassettes/service_manage_additions.yml +65 -0
  30. data/features/cassettes/service_manage_deletions.yml +58 -0
  31. data/features/cassettes/service_manage_deletions_2.yml +58 -0
  32. data/features/cassettes/unsecured_metadata.yml +89 -0
  33. data/features/complex_types.feature +4 -3
  34. data/features/error_handling.feature +13 -0
  35. data/features/query_builder.feature +30 -9
  36. data/features/service.feature +4 -3
  37. data/features/service_manage.feature +6 -5
  38. data/features/service_methods.feature +3 -2
  39. data/features/ssl.feature +8 -8
  40. data/features/step_definitions/service_steps.rb +32 -74
  41. data/features/support/env.rb +6 -3
  42. data/features/support/hooks.rb +3 -2
  43. data/features/support/pickle.rb +29 -18
  44. data/features/support/vcr.rb +24 -0
  45. data/features/type_conversion.feature +16 -17
  46. data/gemfiles/Gemfile.ruby187 +6 -0
  47. data/lib/ruby_odata.rb +20 -18
  48. data/lib/ruby_odata/association.rb +7 -6
  49. data/lib/ruby_odata/class_builder.rb +31 -14
  50. data/lib/ruby_odata/exceptions.rb +11 -0
  51. data/lib/ruby_odata/helpers.rb +17 -0
  52. data/lib/ruby_odata/operation.rb +5 -6
  53. data/lib/ruby_odata/property_metadata.rb +9 -7
  54. data/lib/ruby_odata/query_builder.rb +127 -63
  55. data/lib/ruby_odata/service.rb +265 -147
  56. data/lib/ruby_odata/version.rb +3 -1
  57. data/ruby_odata.gemspec +22 -13
  58. data/spec/fixtures/error_without_message.xml +5 -0
  59. data/spec/fixtures/int64_ids/edmx_boat_service.xml +19 -0
  60. data/spec/fixtures/int64_ids/edmx_car_service.xml +21 -0
  61. data/spec/fixtures/int64_ids/result_boats.xml +26 -0
  62. data/spec/fixtures/int64_ids/result_cars.xml +28 -0
  63. data/spec/fixtures/ms_system_center/edmx_ms_system_center.xml +1645 -0
  64. data/spec/fixtures/ms_system_center/edmx_ms_system_center_v2.xml +2120 -0
  65. data/spec/fixtures/ms_system_center/hardware_profiles.xml +61 -0
  66. data/spec/fixtures/ms_system_center/virtual_machines.xml +175 -0
  67. data/spec/fixtures/ms_system_center/vm_templates.xml +1193 -0
  68. data/spec/fixtures/nested_expands/edmx_northwind.xml +557 -0
  69. data/spec/fixtures/nested_expands/northwind_products_category_expands.xml +774 -0
  70. data/spec/fixtures/sample_service/result_select_categories_expand.xml +268 -0
  71. data/spec/fixtures/sample_service/result_select_categories_no_property.xml +6 -0
  72. data/spec/fixtures/sample_service/result_select_categories_travsing_no_expand.xml +6 -0
  73. data/spec/fixtures/sample_service/result_select_products_name_price.xml +92 -0
  74. data/spec/property_metadata_spec.rb +10 -10
  75. data/spec/query_builder_spec.rb +153 -14
  76. data/spec/revised_service_spec.rb +111 -6
  77. data/spec/service_spec.rb +389 -85
  78. data/spec/spec_helper.rb +3 -0
  79. data/spec/support/sample_service_matcher.rb +15 -0
  80. data/test/RubyODataService/RubyODataService/App_Data/.gitkeep +0 -0
  81. data/test/blueprints.rb +15 -9
  82. data/test/usage_samples/querying.rb +5 -1
  83. data/test/usage_samples/sample_data.rb +1 -3
  84. metadata +276 -76
  85. data/CHANGELOG.rdoc +0 -88
  86. data/README.rdoc +0 -259
@@ -1,17 +1,17 @@
1
1
  module OData
2
-
2
+ # The main service class, also known as a *Context*
3
3
  class Service
4
- attr_reader :classes, :class_metadata, :options, :collections, :edmx, :function_imports
4
+ attr_reader :classes, :class_metadata, :options, :collections, :edmx, :function_imports, :response
5
5
  # Creates a new instance of the Service class
6
6
  #
7
- # ==== Required Attributes
8
- # - service_uri: The root URI of the OData service
9
- # ==== Options in options hash
10
- # - username: username for http basic auth
11
- # - password: password for http basic auth
12
- # - verify_ssl: false if no verification, otherwise mode (OpenSSL::SSL::VERIFY_PEER is default)
13
- # - additional_params: a hash of query string params that will be passed on all calls
14
- # - eager_partial: true (default) if queries should consume partial feeds until the feed is complete, false if explicit calls to next must be performed
7
+ # @param [String] service_uri the root URI of the OData service
8
+ # @param [Hash] options the options to pass to the service
9
+ # @option options [String] :username for http basic auth
10
+ # @option options [String] :password for http basic auth
11
+ # @option options [Object] :verify_ssl false if no verification, otherwise mode (OpenSSL::SSL::VERIFY_PEER is default)
12
+ # @option options [Hash] :rest_options a hash of rest-client options that will be passed to all RestClient::Resource.new calls
13
+ # @option options [Hash] :additional_params a hash of query string params that will be passed on all calls
14
+ # @option options [Boolean, true] :eager_partial true if queries should consume partial feeds until the feed is complete, false if explicit calls to next must be performed
15
15
  def initialize(service_uri, options = {})
16
16
  @uri = service_uri.gsub!(/\/?$/, '')
17
17
  set_options! options
@@ -20,13 +20,11 @@ class Service
20
20
  build_collections_and_classes
21
21
  end
22
22
 
23
- # Handles the dynamic AddTo<EntityName> methods as well as the collections on the service
23
+ # Handles the dynamic `AddTo<EntityName>` methods as well as the collections on the service
24
24
  def method_missing(name, *args)
25
25
  # Queries
26
26
  if @collections.include?(name.to_s)
27
- root = "/#{name.to_s}"
28
- root << "(#{args.join(',')})" unless args.empty?
29
- @query = QueryBuilder.new(root, @additional_params)
27
+ @query = build_collection_query_object(name,@additional_params, *args)
30
28
  return @query
31
29
  # Adds
32
30
  elsif name.to_s =~ /^AddTo(.*)/
@@ -45,31 +43,29 @@ class Service
45
43
 
46
44
  # Queues an object for deletion. To actually remove it from the server, you must call save_changes as well.
47
45
  #
48
- # ==== Required Attributes
49
- # - obj: The object to mark for deletion
46
+ # @param [Object] obj the object to mark for deletion
50
47
  #
51
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
48
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
52
49
  def delete_object(obj)
53
50
  type = obj.class.to_s
54
51
  if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
55
52
  @save_operations << Operation.new("Delete", type, obj)
56
53
  else
57
- raise "You cannot delete a non-tracked entity"
54
+ raise OData::NotSupportedError.new "You cannot delete a non-tracked entity"
58
55
  end
59
56
  end
60
57
 
61
58
  # Queues an object for update. To actually update it on the server, you must call save_changes as well.
62
59
  #
63
- # ==== Required Attributes
64
- # - obj: The object to queue for update
60
+ # @param [Object] obj the object to queue for update
65
61
  #
66
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
62
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
67
63
  def update_object(obj)
68
64
  type = obj.class.to_s
69
65
  if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
70
66
  @save_operations << Operation.new("Update", type, obj)
71
67
  else
72
- raise "You cannot update a non-tracked entity"
68
+ raise OData::NotSupportedError.new "You cannot update a non-tracked entity"
73
69
  end
74
70
  end
75
71
 
@@ -79,23 +75,34 @@ class Service
79
75
 
80
76
  result = nil
81
77
 
82
- if @save_operations.length == 1
83
- result = single_save(@save_operations[0])
84
- else
85
- result = batch_save(@save_operations)
86
- end
78
+ begin
79
+ if @save_operations.length == 1
80
+ result = single_save(@save_operations[0])
81
+ else
82
+ result = batch_save(@save_operations)
83
+ end
87
84
 
88
- # TODO: We should probably perform a check here
89
- # to make sure everything worked before clearing it out
90
- @save_operations.clear
85
+ # TODO: We should probably perform a check here
86
+ # to make sure everything worked before clearing it out
87
+ @save_operations.clear
91
88
 
92
- return result
89
+ return result
90
+ rescue Exception => e
91
+ handle_exception(e)
92
+ end
93
93
  end
94
94
 
95
- # Performs query operations (Read) against the server, returns an array of record instances.
95
+ # Performs query operations (Read) against the server.
96
+ # Typically this returns an array of record instances, except in the case of count queries
97
+ # @raise [ServiceError] if there is an error when talking to the service
96
98
  def execute
97
- result = RestClient::Resource.new(build_query_uri, @rest_options).get
98
- handle_collection_result(result)
99
+ begin
100
+ @response = RestClient::Resource.new(build_query_uri, @rest_options).get
101
+ rescue Exception => e
102
+ handle_exception(e)
103
+ end
104
+ return Integer(@response) if @response =~ /^\d+$/
105
+ handle_collection_result(@response)
99
106
  end
100
107
 
101
108
  # Overridden to identify methods handled by method_missing
@@ -118,7 +125,7 @@ class Service
118
125
  end
119
126
  end
120
127
 
121
- # Retrieves the next resultset of a partial result (if any). Does not honor the :eager_partial option.
128
+ # Retrieves the next resultset of a partial result (if any). Does not honor the `:eager_partial` option.
122
129
  def next
123
130
  return if not partial?
124
131
  handle_partial
@@ -131,14 +138,13 @@ class Service
131
138
 
132
139
  # Lazy loads a navigation property on a model
133
140
  #
134
- # ==== Required Attributes
135
- # - obj: The object to fill
136
- # - nav_prop: The navigation property to fill
141
+ # @param [Object] obj the object to fill
142
+ # @param [String] nav_prop the navigation property to fill
137
143
  #
138
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
139
- # Note: This method will throw an exception if the +nav_prop+ isn't a valid navigation property
144
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
145
+ # @raise [ArgumentError] if the `nav_prop` isn't a valid navigation property
140
146
  def load_property(obj, nav_prop)
141
- raise ArgumentError, "You cannot load a property on an entity that isn't tracked" if obj.send(:__metadata).nil?
147
+ raise NotSupportedError, "You cannot load a property on an entity that isn't tracked" if obj.send(:__metadata).nil?
142
148
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property" unless obj.respond_to?(nav_prop.to_sym)
143
149
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property" unless @class_metadata[obj.class.to_s][nav_prop].nav_prop
144
150
  results = RestClient::Resource.new(build_load_property_uri(obj, nav_prop), @rest_options).get
@@ -148,28 +154,77 @@ class Service
148
154
 
149
155
  # Adds a child object to a parent object's collection
150
156
  #
151
- # ==== Required Attributes
152
- # - parent: The parent object
153
- # - nav_prop: The name of the navigation property to add the child to
154
- # - child: The child object
157
+ # @param [Object] parent the parent object
158
+ # @param [String] nav_prop the name of the navigation property to add the child to
159
+ # @param [Object] child the child object
160
+ # @raise [NotSupportedError] if the `parent` isn't a tracked entity
161
+ # @raise [ArgumentError] if the `nav_prop` isn't a valid navigation property
162
+ # @raise [NotSupportedError] if the `child` isn't a tracked entity
155
163
  def add_link(parent, nav_prop, child)
156
- raise ArgumentError, "You cannot add a link on an entity that isn't tracked (#{parent.class})" if parent.send(:__metadata).nil?
164
+ raise NotSupportedError, "You cannot add a link on an entity that isn't tracked (#{parent.class})" if parent.send(:__metadata).nil?
157
165
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property for #{parent.class}" unless parent.respond_to?(nav_prop.to_sym)
158
166
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property for #{parent.class}" unless @class_metadata[parent.class.to_s][nav_prop].nav_prop
159
- raise ArgumentError, "You cannot add a link on a child entity that isn't tracked (#{child.class})" if child.send(:__metadata).nil?
167
+ raise NotSupportedError, "You cannot add a link on a child entity that isn't tracked (#{child.class})" if child.send(:__metadata).nil?
160
168
  @save_operations << Operation.new("AddLink", nav_prop, parent, child)
161
169
  end
162
170
 
163
171
  private
164
172
 
173
+ # Constructs a QueryBuilder instance for a collection using the arguments provided.
174
+ #
175
+ # @param [String] name the name of the collection
176
+ # @param [Hash] additional_parameters the additional parameters
177
+ # @param [Array] args the arguments to use for query
178
+ def build_collection_query_object(name, additional_parameters, *args)
179
+ root = "/#{name.to_s}"
180
+ if args.empty?
181
+ #nothing to add
182
+ elsif args.size == 1
183
+ if args.first.to_s =~ /\d+/
184
+ id_metadata = find_id_metadata(name.to_s)
185
+ root << build_id_path(args.first, id_metadata)
186
+ else
187
+ root << "(#{args.first})"
188
+ end
189
+ else
190
+ root << "(#{args.join(',')})"
191
+ end
192
+ QueryBuilder.new(root, additional_parameters)
193
+ end
194
+
195
+ # Finds the metadata associated with the given collection's first id property
196
+ # Remarks: This is used for single item lookup queries using the ID, e.g. Products(1), not complex primary keys
197
+ #
198
+ # @param [String] collection_name the name of the collection
199
+ def find_id_metadata(collection_name)
200
+ collection_data = @collections.fetch(collection_name)
201
+ class_metadata = @class_metadata.fetch(collection_data[:type].to_s)
202
+ key = class_metadata.select{|k,h| h.is_key }.collect{|k,h| h.name }[0]
203
+ class_metadata[key]
204
+ end
205
+
206
+ # Builds the ID expression of a given id for query
207
+ #
208
+ # @param [Object] id_value the actual value to be used
209
+ # @param [PropertyMetadata] id_metadata the property metadata object for the id
210
+ def build_id_path(id_value, id_metadata)
211
+ if id_metadata.type == "Edm.Int64"
212
+ "(#{id_value}L)"
213
+ else
214
+ "(#{id_value})"
215
+ end
216
+ end
217
+
165
218
  def set_options!(options)
166
219
  @options = options
167
220
  if @options[:eager_partial].nil?
168
221
  @options[:eager_partial] = true
169
222
  end
170
223
  @rest_options = { :verify_ssl => get_verify_mode, :user => @options[:username], :password => @options[:password] }
224
+ @rest_options.merge!(options[:rest_options] || {})
171
225
  @additional_params = options[:additional_params] || {}
172
226
  @namespace = options[:namespace]
227
+ @json_type = options[:json_type] || :json
173
228
  end
174
229
 
175
230
  def default_instance_vars!
@@ -179,16 +234,16 @@ class Service
179
234
  @has_partial = false
180
235
  @next_uri = nil
181
236
  end
182
-
237
+
183
238
  def set_namespaces
184
239
  @edmx = Nokogiri::XML(RestClient::Resource.new(build_metadata_uri, @rest_options).get)
185
- @ds_namespaces = {
240
+ @ds_namespaces = {
186
241
  "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
187
242
  "edmx" => "http://schemas.microsoft.com/ado/2007/06/edmx",
188
243
  "ds" => "http://schemas.microsoft.com/ado/2007/08/dataservices",
189
244
  "atom" => "http://www.w3.org/2005/Atom"
190
245
  }
191
-
246
+
192
247
  # Get the edm namespace from the edmx
193
248
  edm_ns = @edmx.xpath("edmx:Edmx/edmx:DataServices/*", @namespaces).first.namespaces['xmlns'].to_s
194
249
  @ds_namespaces.merge! "edm" => edm_ns
@@ -232,7 +287,7 @@ class Service
232
287
  entity_type = c["EntityType"]
233
288
  @collections[c["Name"]] = { :edmx_type => entity_type, :type => convert_to_local_type(entity_type) }
234
289
  end
235
-
290
+
236
291
  build_function_imports
237
292
  end
238
293
 
@@ -241,7 +296,18 @@ class Service
241
296
  # Fill in the function imports
242
297
  functions = @edmx.xpath("//edm:EntityContainer/edm:FunctionImport", @ds_namespaces)
243
298
  functions.each do |f|
244
- http_method = f.xpath("@m:HttpMethod", @ds_namespaces).first.content
299
+ http_method_attribute = f.xpath("@m:HttpMethod", @ds_namespaces).first # HttpMethod is no longer required http://www.odata.org/2011/10/actions-in-odata/
300
+ is_side_effecting_attribute = f.xpath("@edm:IsSideEffecting", @ds_namespaces).first
301
+
302
+ http_method = 'POST' # default to POST
303
+
304
+ if http_method_attribute
305
+ http_method = http_method_attribute.content
306
+ elsif is_side_effecting_attribute
307
+ is_side_effecting = is_side_effecting_attribute.content
308
+ http_method = is_side_effecting ? 'POST' : 'GET'
309
+ end
310
+
245
311
  return_type = f["ReturnType"]
246
312
  inner_return_type = nil
247
313
  unless return_type.nil?
@@ -258,9 +324,9 @@ class Service
258
324
  parameters[p["Name"]] = p["Type"]
259
325
  end
260
326
  end
261
- @function_imports[f["Name"]] = {
262
- :http_method => http_method,
263
- :return_type => return_type,
327
+ @function_imports[f["Name"]] = {
328
+ :http_method => http_method,
329
+ :return_type => return_type,
264
330
  :inner_return_type => inner_return_type,
265
331
  :parameters => parameters }
266
332
  end
@@ -284,17 +350,19 @@ class Service
284
350
  end
285
351
 
286
352
  # Builds the metadata need for each property for things like feed customizations and navigation properties
287
- def build_property_metadata(props)
353
+ def build_property_metadata(props, keys=[])
288
354
  metadata = {}
289
355
  props.each do |property_element|
290
356
  prop_meta = PropertyMetadata.new(property_element)
357
+ prop_meta.is_key = keys.include?(prop_meta.name)
358
+
291
359
  # If this is a navigation property, we need to add the association to the property metadata
292
360
  prop_meta.association = Association.new(property_element, @edmx) if prop_meta.nav_prop
293
361
  metadata[prop_meta.name] = prop_meta
294
362
  end
295
363
  metadata
296
364
  end
297
-
365
+
298
366
  # Handle parsing of OData Atom result and return an array of Entry classes
299
367
  def handle_collection_result(result)
300
368
  results = build_classes_from_result(result)
@@ -303,11 +371,28 @@ class Service
303
371
  end
304
372
  results
305
373
  end
306
-
374
+
375
+ # Handles errors from the OData service
376
+ def handle_exception(e)
377
+ raise e unless defined? e.response
378
+
379
+ code = e.http_code
380
+ error = Nokogiri::XML(e.response)
381
+
382
+ message = if error.xpath("m:error/m:message", @ds_namespaces).first
383
+ error.xpath("m:error/m:message", @ds_namespaces).first.content
384
+ else
385
+ "Server returned error but no message."
386
+ end
387
+ raise ServiceError.new(code), message
388
+ end
389
+
307
390
  # Loops through the standard properties (non-navigation) for a given class and returns the appropriate list of methods
308
391
  def collect_properties(klass_name, element, doc)
309
392
  props = element.xpath(".//edm:Property", @ds_namespaces)
310
- @class_metadata[klass_name] = build_property_metadata(props)
393
+ key_elemnts = element.xpath(".//edm:Key//edm:PropertyRef", @ds_namespaces)
394
+ keys = key_elemnts.collect { |k| k['Name'] }
395
+ @class_metadata[klass_name] = build_property_metadata(props, keys)
311
396
  methods = props.collect { |p| p['Name'] }
312
397
  unless element["BaseType"].nil?
313
398
  base = element["BaseType"].split(".").last()
@@ -318,7 +403,7 @@ class Service
318
403
  end
319
404
  methods
320
405
  end
321
-
406
+
322
407
  # Similar to +collect_properties+, but handles the navigation properties
323
408
  def collect_navigation_properties(klass_name, element, doc)
324
409
  nav_props = element.xpath(".//edm:NavigationProperty", @ds_namespaces)
@@ -329,14 +414,14 @@ class Service
329
414
  # Helper to loop through a result and create an instance for each entity in the results
330
415
  def build_classes_from_result(result)
331
416
  doc = Nokogiri::XML(result)
332
-
417
+
333
418
  is_links = doc.at_xpath("/ds:links", @ds_namespaces)
334
419
  return parse_link_results(doc) if is_links
335
-
420
+
336
421
  entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
337
-
422
+
338
423
  extract_partial(doc)
339
-
424
+
340
425
  results = []
341
426
  entries.each do |entry|
342
427
  results << entry_to_class(entry)
@@ -348,24 +433,20 @@ class Service
348
433
  def entry_to_class(entry)
349
434
  # Retrieve the class name from the fully qualified name (the last string after the last dot)
350
435
  klass_name = entry.xpath("./atom:category/@term", @ds_namespaces).to_s.split('.')[-1]
351
-
436
+
352
437
  # Is the category missing? See if there is a title that we can use to build the class
353
438
  if klass_name.nil?
354
439
  title = entry.xpath("./atom:title", @ds_namespaces).first
355
440
  return nil if title.nil?
356
441
  klass_name = title.content.to_s
357
442
  end
358
-
443
+
359
444
  return nil if klass_name.nil?
360
445
 
361
- # If we are working against a child (inline) entry, we need to use the more generic xpath because a child entry WILL
362
- # have properties that are ancestors of m:inline. Check if there is an m:inline child to determine the xpath query to use
363
- has_inline = entry.xpath(".//m:inline", @ds_namespaces).any?
364
- properties_xpath = has_inline ? ".//m:properties[not(ancestor::m:inline)]/*" : ".//m:properties/*"
365
- properties = entry.xpath(properties_xpath, @ds_namespaces)
446
+ properties = entry.xpath("./atom:content/m:properties/*", @ds_namespaces)
366
447
 
367
448
  klass = @classes[qualify_class_name(klass_name)].new
368
-
449
+
369
450
  # Fill metadata
370
451
  meta_id = entry.xpath("./atom:id", @ds_namespaces)[0].content
371
452
  klass.send :__metadata=, { :uri => meta_id }
@@ -373,9 +454,9 @@ class Service
373
454
  # Fill properties
374
455
  for prop in properties
375
456
  prop_name = prop.name
376
- klass.send "#{prop_name}=", parse_value(prop)
457
+ klass.send "#{prop_name}=", parse_value_xml(prop)
377
458
  end
378
-
459
+
379
460
  # Fill properties represented outside of the properties collection
380
461
  @class_metadata[qualify_class_name(klass_name)].select { |k,v| v.fc_keep_in_content == false }.each do |k, meta|
381
462
  if meta.fc_target_path == "SyndicationTitle"
@@ -386,19 +467,18 @@ class Service
386
467
  klass.send "#{meta.name}=", summary.content
387
468
  end
388
469
  end
389
-
470
+
390
471
  inline_links = entry.xpath("./atom:link[m:inline]", @ds_namespaces)
391
-
392
- for link in inline_links
393
- inline_entries = link.xpath(".//atom:entry", @ds_namespaces)
394
472
 
395
- # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
473
+ for link in inline_links
474
+ # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
396
475
  property_name = link.attributes['title'].to_s
397
- if inline_entries.length == 1 && singular?(property_name)
398
- inline_klass = build_inline_class(klass, inline_entries[0], property_name)
476
+ if singular?(property_name)
477
+ inline_entry = link.xpath("./m:inline/atom:entry", @ds_namespaces).first
478
+ inline_klass = build_inline_class(klass, inline_entry, property_name)
399
479
  klass.send "#{property_name}=", inline_klass
400
480
  else
401
- inline_classes = []
481
+ inline_classes, inline_entries = [], link.xpath("./m:inline/atom:feed/atom:entry", @ds_namespaces)
402
482
  for inline_entry in inline_entries
403
483
  # Build the class
404
484
  inline_klass = entry_to_class(inline_entry)
@@ -412,17 +492,21 @@ class Service
412
492
  klass.send "#{property_name}=", inline_classes
413
493
  end
414
494
  end
415
-
495
+
416
496
  klass
417
497
  end
418
-
498
+
419
499
  # Tests for and extracts the next href of a partial
420
500
  def extract_partial(doc)
421
501
  next_links = doc.xpath('//atom:link[@rel="next"]', @ds_namespaces)
422
502
  @has_partial = next_links.any?
423
- @next_uri = next_links[0]['href'] if @has_partial
503
+ if @has_partial
504
+ uri = Addressable::URI.parse(next_links[0]['href'])
505
+ uri.query_values = uri.query_values.merge @additional_params unless @additional_params.empty?
506
+ @next_uri = uri.to_s
507
+ end
424
508
  end
425
-
509
+
426
510
  def handle_partial
427
511
  if @next_uri
428
512
  result = RestClient::Resource.new(@next_uri, @rest_options).get
@@ -430,7 +514,7 @@ class Service
430
514
  end
431
515
  results
432
516
  end
433
-
517
+
434
518
  # Handle link results
435
519
  def parse_link_results(doc)
436
520
  uris = doc.xpath("/ds:links/ds:uri", @ds_namespaces)
@@ -440,7 +524,7 @@ class Service
440
524
  results << URI.parse(link)
441
525
  end
442
526
  results
443
- end
527
+ end
444
528
 
445
529
  # Build URIs
446
530
  def build_metadata_uri
@@ -457,23 +541,23 @@ class Service
457
541
  uri
458
542
  end
459
543
  def build_add_link_uri(operation)
460
- uri = "#{operation.klass.send(:__metadata)[:uri]}"
544
+ uri = operation.klass.send(:__metadata)[:uri].dup
461
545
  uri << "/$links/#{operation.klass_name}"
462
546
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
463
547
  uri
464
548
  end
465
549
  def build_resource_uri(operation)
466
- uri = operation.klass.send(:__metadata)[:uri]
550
+ uri = operation.klass.send(:__metadata)[:uri].dup
467
551
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
468
552
  uri
469
553
  end
470
554
  def build_batch_uri
471
555
  uri = "#{@uri}/$batch"
472
556
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
473
- uri
557
+ uri
474
558
  end
475
559
  def build_load_property_uri(obj, property)
476
- uri = obj.__metadata[:uri]
560
+ uri = obj.__metadata[:uri].dup
477
561
  uri << "/#{property}"
478
562
  uri
479
563
  end
@@ -483,11 +567,11 @@ class Service
483
567
  uri << "?#{params.to_query}" unless params.empty?
484
568
  uri
485
569
  end
486
-
570
+
487
571
  def build_inline_class(klass, entry, property_name)
488
572
  # Build the class
489
573
  inline_klass = entry_to_class(entry)
490
-
574
+
491
575
  # Add the property
492
576
  klass.send "#{property_name}=", inline_klass
493
577
  end
@@ -516,12 +600,12 @@ class Service
516
600
  if operation.kind == "Add"
517
601
  save_uri = build_save_uri(operation)
518
602
  json_klass = operation.klass.to_json(:type => :add)
519
- post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => :json}
603
+ post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => @json_type}
520
604
  return build_classes_from_result(post_result)
521
605
  elsif operation.kind == "Update"
522
606
  update_uri = build_resource_uri(operation)
523
607
  json_klass = operation.klass.to_json
524
- update_result = RestClient::Resource.new(update_uri, @rest_options).put json_klass, {:content_type => :json}
608
+ update_result = RestClient::Resource.new(update_uri, @rest_options).put json_klass, {:content_type => @json_type}
525
609
  return (update_result.code == 204)
526
610
  elsif operation.kind == "Delete"
527
611
  delete_uri = build_resource_uri(operation)
@@ -530,7 +614,7 @@ class Service
530
614
  elsif operation.kind == "AddLink"
531
615
  save_uri = build_add_link_uri(operation)
532
616
  json_klass = operation.child_klass.to_json(:type => :link)
533
- post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => :json}
617
+ post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => @json_type}
534
618
 
535
619
  # Attach the child to the parent
536
620
  link_child_to_parent(operation) if (post_result.code == 204)
@@ -543,20 +627,20 @@ class Service
543
627
  def generate_guid
544
628
  rand(36**12).to_s(36).insert(4, "-").insert(9, "-")
545
629
  end
546
- def batch_save(operations)
630
+ def batch_save(operations)
547
631
  batch_num = generate_guid
548
632
  changeset_num = generate_guid
549
633
  batch_uri = build_batch_uri
550
-
634
+
551
635
  body = build_batch_body(operations, batch_num, changeset_num)
552
636
  result = RestClient::Resource.new( batch_uri, @rest_options).post body, {:content_type => "multipart/mixed; boundary=batch_#{batch_num}"}
553
637
 
554
- # TODO: More result validation needs to be done.
638
+ # TODO: More result validation needs to be done.
555
639
  # The result returns HTTP 202 even if there is an error in the batch
556
640
  return (result.code == 202)
557
641
  end
558
642
  def build_batch_body(operations, batch_num, changeset_num)
559
- # Header
643
+ # Header
560
644
  body = "--batch_#{batch_num}\n"
561
645
  body << "Content-Type: multipart/mixed;boundary=changeset_#{changeset_num}\n\n"
562
646
 
@@ -565,115 +649,149 @@ class Service
565
649
  body << build_batch_operation(operation, changeset_num)
566
650
  body << "\n"
567
651
  end
568
-
569
- # Footer
652
+
653
+ # Footer
570
654
  body << "\n\n--changeset_#{changeset_num}--\n"
571
655
  body << "--batch_#{batch_num}--"
572
-
656
+
573
657
  return body
574
658
  end
575
659
  def build_batch_operation(operation, changeset_num)
576
660
  accept_headers = "Accept-Charset: utf-8\n"
577
661
  accept_headers << "Content-Type: application/json;charset=utf-8\n" unless operation.kind == "Delete"
578
662
  accept_headers << "\n"
579
-
663
+
580
664
  content = "--changeset_#{changeset_num}\n"
581
665
  content << "Content-Type: application/http\n"
582
666
  content << "Content-Transfer-Encoding: binary\n\n"
583
-
584
- if operation.kind == "Add"
667
+
668
+ if operation.kind == "Add"
585
669
  save_uri = "#{@uri}/#{operation.klass_name}"
586
670
  json_klass = operation.klass.to_json(:type => :add)
587
-
671
+
588
672
  content << "POST #{save_uri} HTTP/1.1\n"
589
673
  content << accept_headers
590
674
  content << json_klass
591
675
  elsif operation.kind == "Update"
592
676
  update_uri = operation.klass.send(:__metadata)[:uri]
593
677
  json_klass = operation.klass.to_json
594
-
678
+
595
679
  content << "PUT #{update_uri} HTTP/1.1\n"
596
680
  content << accept_headers
597
681
  content << json_klass
598
682
  elsif operation.kind == "Delete"
599
683
  delete_uri = operation.klass.send(:__metadata)[:uri]
600
-
684
+
601
685
  content << "DELETE #{delete_uri} HTTP/1.1\n"
602
686
  content << accept_headers
603
687
  elsif
604
688
  save_uri = build_add_link_uri(operation)
605
689
  json_klass = operation.child_klass.to_json(:type => :link)
606
-
690
+
607
691
  content << "POST #{save_uri} HTTP/1.1\n"
608
692
  content << accept_headers
609
693
  content << json_klass
610
694
  link_child_to_parent(operation)
611
695
  end
612
-
696
+
613
697
  return content
614
698
  end
615
699
 
616
700
  # Complex Types
617
701
  def complex_type_to_class(complex_type_xml)
618
- klass_name = qualify_class_name(complex_type_xml.attr('type').split('.')[-1])
619
- klass = @classes[klass_name].new
702
+ type = Helpers.get_namespaced_attribute(complex_type_xml, 'type', 'm')
703
+
704
+ is_collection = false
705
+ # Extract the class name in case this is a Collection
706
+ if type =~ /\(([^)]*)\)/m
707
+ type = $~[1]
708
+ is_collection = true
709
+ collection = []
710
+ end
711
+
712
+ klass_name = qualify_class_name(type.split('.')[-1])
713
+
714
+ if is_collection
715
+ # extract the elements from the collection
716
+ elements = complex_type_xml.xpath(".//d:element", @namespaces)
717
+ elements.each do |e|
718
+ if type.match(/^Edm/)
719
+ collection << parse_value(e.content, type)
720
+ else
721
+ element = @classes[klass_name].new
722
+ fill_complex_type_properties(e, element)
723
+ collection << element
724
+ end
725
+ end
726
+ return collection
727
+ else
728
+ klass = @classes[klass_name].new
729
+ # Fill in the properties
730
+ fill_complex_type_properties(complex_type_xml, klass)
731
+ return klass
732
+ end
733
+ end
620
734
 
621
- # Fill in the properties
735
+ # Helper method for complex_type_to_class
736
+ def fill_complex_type_properties(complex_type_xml, klass)
622
737
  properties = complex_type_xml.xpath(".//*")
623
738
  properties.each do |prop|
624
- klass.send "#{prop.name}=", parse_value(prop)
739
+ klass.send "#{prop.name}=", parse_value_xml(prop)
625
740
  end
626
-
627
- return klass
628
741
  end
629
742
 
630
743
  # Field Converters
631
-
744
+
632
745
  # Handles parsing datetimes from a string
633
746
  def parse_date(sdate)
634
747
  # Assume this is UTC if no timezone is specified
635
748
  sdate = sdate + "Z" unless sdate.match(/Z|([+|-]\d{2}:\d{2})$/)
636
-
749
+
637
750
  # This is to handle older versions of Ruby (e.g. ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mingw32])
638
751
  # See http://makandra.com/notes/1017-maximum-representable-value-for-a-ruby-time-object
639
752
  # In recent versions of Ruby, Time has a much larger range
640
753
  begin
641
- result = Time.parse(sdate)
754
+ result = Time.parse(sdate)
642
755
  rescue ArgumentError
643
756
  result = DateTime.parse(sdate)
644
757
  end
645
-
758
+
646
759
  return result
647
760
  end
648
-
761
+
649
762
  # Parses a value into the proper type based on an xml property element
650
- def parse_value(property_xml)
651
- property_type = property_xml.attr('type')
652
- property_null = property_xml.attr('null')
763
+ def parse_value_xml(property_xml)
764
+ property_type = Helpers.get_namespaced_attribute(property_xml, 'type', 'm')
765
+ property_null = Helpers.get_namespaced_attribute(property_xml, 'null', 'm')
653
766
 
654
- # Handle a nil property type, this is a string
655
- return property_xml.content if property_type.nil?
767
+ if property_type.nil? || (property_type && property_type.match(/^Edm/))
768
+ return parse_value(property_xml.content, property_type, property_null)
769
+ end
656
770
 
771
+ complex_type_to_class(property_xml)
772
+ end
773
+
774
+ def parse_value(content, property_type = nil, property_null = nil)
657
775
  # Handle anything marked as null
658
776
  return nil if !property_null.nil? && property_null == "true"
659
777
 
660
- # Handle complex types
661
- return complex_type_to_class(property_xml) if !property_type.match(/^Edm/)
778
+ # Handle a nil property type, this is a string
779
+ return content if property_type.nil?
662
780
 
663
781
  # Handle integers
664
- return property_xml.content.to_i if property_type.match(/^Edm.Int/)
782
+ return content.to_i if property_type.match(/^Edm.Int/)
665
783
 
666
784
  # Handle decimals
667
- return property_xml.content.to_d if property_type.match(/Edm.Decimal/)
785
+ return content.to_d if property_type.match(/Edm.Decimal/)
668
786
 
669
787
  # Handle DateTimes
670
788
  # return Time.parse(property_xml.content) if property_type.match(/Edm.DateTime/)
671
- return parse_date(property_xml.content) if property_type.match(/Edm.DateTime/)
789
+ return parse_date(content) if property_type.match(/Edm.DateTime/)
672
790
 
673
791
  # If we can't parse the value, just return the element's content
674
- property_xml.content
792
+ content
675
793
  end
676
-
794
+
677
795
  # Parses a value into the proper type based on a specified return type
678
796
  def parse_primative_type(value, return_type)
679
797
  return value.to_i if return_type == Fixnum
@@ -681,7 +799,7 @@ class Service
681
799
  return parse_date(value.to_s) if return_type == Time
682
800
  return value.to_s
683
801
  end
684
-
802
+
685
803
  # Converts an edm type (string) to a ruby type
686
804
  def edm_to_ruby_type(edm_type)
687
805
  return String if edm_type =~ /Edm.String/
@@ -690,30 +808,30 @@ class Service
690
808
  return Time if edm_type =~ /Edm.DateTime/
691
809
  return String
692
810
  end
693
-
811
+
694
812
  # Method Missing Handlers
695
-
813
+
696
814
  # Executes an import function
697
815
  def execute_import_function(name, *args)
698
816
  func = @function_imports[name]
699
-
817
+
700
818
  # Check the args making sure that more weren't passed in than the function needs
701
819
  param_count = func[:parameters].nil? ? 0 : func[:parameters].count
702
820
  arg_count = args.nil? ? 0 : args[0].count
703
821
  if arg_count > param_count
704
822
  raise ArgumentError, "wrong number of arguments (#{arg_count} for #{param_count})"
705
823
  end
706
-
824
+
707
825
  # Convert the parameters to a hash
708
826
  params = {}
709
827
  func[:parameters].keys.each_with_index { |key, i| params[key] = args[0][i] } unless func[:parameters].nil?
710
-
828
+
711
829
  function_uri = build_function_import_uri(name, params)
712
830
  result = RestClient::Resource.new(function_uri, @rest_options).send(func[:http_method].downcase, {})
713
-
831
+
714
832
  # Is this a 204 (No content) result?
715
833
  return true if result.code == 204
716
-
834
+
717
835
  # No? Then we need to parse the results. There are 4 kinds...
718
836
  if func[:return_type] == Array
719
837
  # a collection of entites
@@ -726,19 +844,19 @@ class Service
726
844
  end
727
845
  return results
728
846
  end
729
-
847
+
730
848
  # a single entity
731
849
  if @classes.include?(func[:return_type].to_s)
732
850
  entry = Nokogiri::XML(result).xpath("atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
733
851
  return entry_to_class(entry)
734
852
  end
735
-
853
+
736
854
  # or a single native type
737
855
  unless func[:return_type].nil?
738
856
  e = Nokogiri::XML(result).xpath("/*").first
739
857
  return parse_primative_type(e.content, func[:return_type])
740
858
  end
741
-
859
+
742
860
  # Nothing could be parsed, so just return if we got a 200 or not
743
861
  return (result.code == 200)
744
862
  end
@@ -749,4 +867,4 @@ class Service
749
867
  end
750
868
  end
751
869
 
752
- end # module OData
870
+ end # module OData