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.
- 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
|