ruby_odata 0.1.0 → 0.1.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.
- checksums.yaml +7 -0
- data/.coveralls.yml +1 -0
- data/.gitignore +7 -2
- data/.simplecov +10 -0
- data/.travis.yml +10 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +157 -0
- data/Guardfile +14 -0
- data/LICENSE +20 -22
- data/README.md +289 -0
- data/Rakefile +19 -9
- data/features/basic_auth.feature +3 -2
- data/features/batch_request.feature +7 -6
- data/features/cassettes/basic_auth_protected_resource.yml +57 -0
- data/features/cassettes/batch_request_additions.yml +69 -0
- data/features/cassettes/batch_request_deletes.yml +69 -0
- data/features/cassettes/batch_request_updates.yml +69 -0
- data/features/cassettes/clean_database_for_testing.yml +46 -0
- data/features/cassettes/cucumber_tags/basic_auth.yml +297 -0
- data/features/cassettes/cucumber_tags/batch_request.yml +1459 -0
- data/features/cassettes/cucumber_tags/complex_types.yml +326 -0
- data/features/cassettes/cucumber_tags/error_handling.yml +64 -0
- data/features/cassettes/cucumber_tags/query_builder.yml +2025 -0
- data/features/cassettes/cucumber_tags/service.yml +234 -0
- data/features/cassettes/cucumber_tags/service_manage.yml +937 -0
- data/features/cassettes/cucumber_tags/service_methods.yml +647 -0
- data/features/cassettes/cucumber_tags/ssl.yml +203 -0
- data/features/cassettes/cucumber_tags/type_conversion.yml +337 -0
- data/features/cassettes/service_manage_additions.yml +65 -0
- data/features/cassettes/service_manage_deletions.yml +58 -0
- data/features/cassettes/service_manage_deletions_2.yml +58 -0
- data/features/cassettes/unsecured_metadata.yml +89 -0
- data/features/complex_types.feature +4 -3
- data/features/error_handling.feature +13 -0
- data/features/query_builder.feature +30 -9
- data/features/service.feature +4 -3
- data/features/service_manage.feature +6 -5
- data/features/service_methods.feature +3 -2
- data/features/ssl.feature +8 -8
- data/features/step_definitions/service_steps.rb +32 -74
- data/features/support/env.rb +6 -3
- data/features/support/hooks.rb +3 -2
- data/features/support/pickle.rb +29 -18
- data/features/support/vcr.rb +24 -0
- data/features/type_conversion.feature +16 -17
- data/gemfiles/Gemfile.ruby187 +6 -0
- data/lib/ruby_odata.rb +20 -18
- data/lib/ruby_odata/association.rb +7 -6
- data/lib/ruby_odata/class_builder.rb +31 -14
- data/lib/ruby_odata/exceptions.rb +11 -0
- data/lib/ruby_odata/helpers.rb +17 -0
- data/lib/ruby_odata/operation.rb +5 -6
- data/lib/ruby_odata/property_metadata.rb +9 -7
- data/lib/ruby_odata/query_builder.rb +127 -63
- data/lib/ruby_odata/service.rb +265 -147
- data/lib/ruby_odata/version.rb +3 -1
- data/ruby_odata.gemspec +22 -13
- data/spec/fixtures/error_without_message.xml +5 -0
- data/spec/fixtures/int64_ids/edmx_boat_service.xml +19 -0
- data/spec/fixtures/int64_ids/edmx_car_service.xml +21 -0
- data/spec/fixtures/int64_ids/result_boats.xml +26 -0
- data/spec/fixtures/int64_ids/result_cars.xml +28 -0
- data/spec/fixtures/ms_system_center/edmx_ms_system_center.xml +1645 -0
- data/spec/fixtures/ms_system_center/edmx_ms_system_center_v2.xml +2120 -0
- data/spec/fixtures/ms_system_center/hardware_profiles.xml +61 -0
- data/spec/fixtures/ms_system_center/virtual_machines.xml +175 -0
- data/spec/fixtures/ms_system_center/vm_templates.xml +1193 -0
- data/spec/fixtures/nested_expands/edmx_northwind.xml +557 -0
- data/spec/fixtures/nested_expands/northwind_products_category_expands.xml +774 -0
- data/spec/fixtures/sample_service/result_select_categories_expand.xml +268 -0
- data/spec/fixtures/sample_service/result_select_categories_no_property.xml +6 -0
- data/spec/fixtures/sample_service/result_select_categories_travsing_no_expand.xml +6 -0
- data/spec/fixtures/sample_service/result_select_products_name_price.xml +92 -0
- data/spec/property_metadata_spec.rb +10 -10
- data/spec/query_builder_spec.rb +153 -14
- data/spec/revised_service_spec.rb +111 -6
- data/spec/service_spec.rb +389 -85
- data/spec/spec_helper.rb +3 -0
- data/spec/support/sample_service_matcher.rb +15 -0
- data/test/RubyODataService/RubyODataService/App_Data/.gitkeep +0 -0
- data/test/blueprints.rb +15 -9
- data/test/usage_samples/querying.rb +5 -1
- data/test/usage_samples/sample_data.rb +1 -3
- metadata +276 -76
- data/CHANGELOG.rdoc +0 -88
- data/README.rdoc +0 -259
data/lib/ruby_odata/service.rb
CHANGED
@@ -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
|
-
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
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
|
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
|
-
|
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
|
-
#
|
49
|
-
# - obj: The object to mark for deletion
|
46
|
+
# @param [Object] obj the object to mark for deletion
|
50
47
|
#
|
51
|
-
#
|
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
|
-
#
|
64
|
-
# - obj: The object to queue for update
|
60
|
+
# @param [Object] obj the object to queue for update
|
65
61
|
#
|
66
|
-
#
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
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
|
-
|
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
|
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
|
-
|
98
|
-
|
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
|
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
|
-
#
|
135
|
-
#
|
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
|
-
#
|
139
|
-
#
|
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
|
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
|
-
#
|
152
|
-
#
|
153
|
-
#
|
154
|
-
#
|
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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
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}=",
|
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
|
-
|
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
|
398
|
-
|
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
|
-
|
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 =
|
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 =>
|
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 =>
|
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 =>
|
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
|
-
|
619
|
-
|
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
|
-
|
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}=",
|
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
|
651
|
-
property_type =
|
652
|
-
property_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
|
-
|
655
|
-
|
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
|
661
|
-
return
|
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
|
782
|
+
return content.to_i if property_type.match(/^Edm.Int/)
|
665
783
|
|
666
784
|
# Handle decimals
|
667
|
-
return
|
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(
|
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
|
-
|
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
|