lib/ruby_odata/service.rb in ruby_odata-0.0.7 vs lib/ruby_odata/service.rb in ruby_odata-0.0.8
- old
+ new
@@ -1,349 +1,369 @@
require 'logger'
+require 'base64'
module OData
-
+
class Service
- attr_reader :classes
- # Creates a new instance of the Service class
- #
- # ==== Required Attributes
- # - service_uri: The root URI of the OData service
- def initialize(service_uri)
- @uri = service_uri
- @collections = get_collections
- @save_operations = []
- build_classes
- end
-
- # Handles the dynamic AddTo<EntityName> methods as well as the collections on the service
- def method_missing(name, *args)
- # Queries
- if @collections.include?(name.to_s)
- root = "/#{name.to_s.camelize}"
- root << "(#{args.join(',')})" unless args.empty?
- @query = QueryBuilder.new(root)
- return @query
- # Adds
- elsif name.to_s =~ /^AddTo(.*)/
- type = $1
- if @collections.include?(type)
- @save_operations << Operation.new("Add", $1, args[0])
- else
- super
- end
- else
- super
- end
+ attr_reader :classes
+ # Creates a new instance of the Service class
+ #
+ # ==== Required Attributes
+ # - service_uri: The root URI of the OData service
+ def initialize(service_uri, options = {})
+ @uri = service_uri
+ if not options[:username].nil?
+ @auth_header = "Basic " + Base64.encode64( options[:username] + ":" + (options[:password] || "") )
+ @http_headers = {:Authorization => @auth_header}
+ else
+ @http_headers = {}
+ end
+ @collections = get_collections
+ @save_operations = []
+ build_classes
+ end
+
+ # Handles the dynamic AddTo<EntityName> methods as well as the collections on the service
+ def method_missing(name, *args)
+ # Queries
+ if @collections.include?(name.to_s)
+ root = "/#{name.to_s.camelize}"
+ root << "(#{args.join(',')})" unless args.empty?
+ @query = QueryBuilder.new(root)
+ return @query
+ # Adds
+ elsif name.to_s =~ /^AddTo(.*)/
+ type = $1
+ if @collections.include?(type)
+ @save_operations << Operation.new("Add", $1, args[0])
+ else
+ super
+ end
+ else
+ super
+ end
- end
+ end
- # Queues an object for deletion. To actually remove it from the server, you must call save_changes as well.
- #
- # ==== Required Attributes
- # - obj: The object to mark for deletion
- #
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
- def delete_object(obj)
- type = obj.class.to_s
- if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
- @save_operations << Operation.new("Delete", type, obj)
- else
- raise "You cannot delete a non-tracked entity"
- end
- end
-
- # Queues an object for update. To actually update it on the server, you must call save_changes as well.
- #
- # ==== Required Attributes
- # - obj: The object to queue for update
- #
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
- def update_object(obj)
- type = obj.class.to_s
- if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
- @save_operations << Operation.new("Update", type, obj)
- else
- raise "You cannot update a non-tracked entity"
- end
- end
-
- # Performs save operations (Create/Update/Delete) against the server
- def save_changes
- return nil if @save_operations.empty?
+ # Queues an object for deletion. To actually remove it from the server, you must call save_changes as well.
+ #
+ # ==== Required Attributes
+ # - obj: The object to mark for deletion
+ #
+ # Note: This method will throw an exception if the +obj+ isn't a tracked entity
+ def delete_object(obj)
+ type = obj.class.to_s
+ if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
+ @save_operations << Operation.new("Delete", type, obj)
+ else
+ raise "You cannot delete a non-tracked entity"
+ end
+ end
+
+ # Queues an object for update. To actually update it on the server, you must call save_changes as well.
+ #
+ # ==== Required Attributes
+ # - obj: The object to queue for update
+ #
+ # Note: This method will throw an exception if the +obj+ isn't a tracked entity
+ def update_object(obj)
+ type = obj.class.to_s
+ if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
+ @save_operations << Operation.new("Update", type, obj)
+ else
+ raise "You cannot update a non-tracked entity"
+ end
+ end
+
+ # Performs save operations (Create/Update/Delete) against the server
+ def save_changes
+ return nil if @save_operations.empty?
- result = nil
-
- if @save_operations.length == 1
- result = single_save(@save_operations[0])
- else
- result = batch_save(@save_operations)
- end
-
- # TODO: We should probably perform a check here
- # to make sure everything worked before clearing it out
- @save_operations.clear
-
- return result
- end
+ result = nil
+
+ if @save_operations.length == 1
+ result = single_save(@save_operations[0])
+ else
+ result = batch_save(@save_operations)
+ end
+
+ # TODO: We should probably perform a check here
+ # to make sure everything worked before clearing it out
+ @save_operations.clear
+
+ return result
+ end
- # Performs query operations (Read) against the server
- def execute
- result = RestClient.get build_query_uri
- build_classes_from_result(result)
- end
-
- # Overridden to identify methods handled by method_missing
- def respond_to?(method)
- if @collections.include?(method.to_s)
- return true
- # Adds
- elsif method.to_s =~ /^AddTo(.*)/
- type = $1
- if @collections.include?(type)
- return true
- else
- super
- end
- else
- super
- end
- end
-
- private
- # Retrieves collections from the main service page
- def get_collections
- doc = Nokogiri::XML(open(@uri))
- collections = doc.xpath("//app:collection", "app" => "http://www.w3.org/2007/app")
- collections.collect { |c| c["href"] }
- end
+ # Performs query operations (Read) against the server
+ def execute
+ result = RestClient.get build_query_uri, @http_headers
+ build_classes_from_result(result)
+ end
+
+ # Overridden to identify methods handled by method_missing
+ def respond_to?(method)
+ if @collections.include?(method.to_s)
+ return true
+ # Adds
+ elsif method.to_s =~ /^AddTo(.*)/
+ type = $1
+ if @collections.include?(type)
+ return true
+ else
+ super
+ end
+ else
+ super
+ end
+ end
+
+ private
+ # Wrapper around open call to make http request
+ def http_open( address )
+ if not @auth_header.nil?
+ return open(address, "Authorization" => @auth_header)
+ else
+ return open(address)
+ end
+ end
- # Build the classes required by the metadata
- def build_classes
- @classes = Hash.new
- doc = Nokogiri::XML(open("#{@uri}/$metadata"))
+ # Retrieves collections from the main service page
+ def get_collections
+ doc = Nokogiri::XML(http_open(@uri))
+ collections = doc.xpath("//app:collection", "app" => "http://www.w3.org/2007/app")
+ collections.collect { |c| c["href"] }
+ end
- # Get the edm namespace
- edm_ns = doc.xpath("edmx:Edmx/edmx:DataServices/*", "edmx" => "http://schemas.microsoft.com/ado/2007/06/edmx").first.namespaces['xmlns'].to_s
+ # Build the classes required by the metadata
+ def build_classes
+ @classes = Hash.new
+ doc = Nokogiri::XML(http_open("#{@uri}/$metadata"))
- # Build complex types first, these will be used for entities
- complex_types = doc.xpath("//edm:ComplexType", "edm" => edm_ns) || []
- complex_types.each do |c|
- name = c['Name']
- props = c.xpath(".//edm:Property", "edm" => edm_ns)
- methods = props.collect { |p| p['Name'] } # Standard Properties
- @classes[name] = ClassBuilder.new(name, methods, []).build unless @classes.keys.include?(name)
- end
+ # Get the edm namespace
+ edm_ns = doc.xpath("edmx:Edmx/edmx:DataServices/*", "edmx" => "http://schemas.microsoft.com/ado/2007/06/edmx").first.namespaces['xmlns'].to_s
- entity_types = doc.xpath("//edm:EntityType", "edm" => edm_ns)
- entity_types.each do |e|
- name = e['Name']
- props = e.xpath(".//edm:Property", "edm" => edm_ns)
- methods = props.collect { |p| p['Name'] } # Standard Properties
- nprops = e.xpath(".//edm:NavigationProperty", "edm" => edm_ns)
- nav_props = nprops.collect { |p| p['Name'] } # Standard Properties
- @classes[name] = ClassBuilder.new(name, methods, nav_props).build unless @classes.keys.include?(name)
- end
- end
+ # Build complex types first, these will be used for entities
+ complex_types = doc.xpath("//edm:ComplexType", "edm" => edm_ns) || []
+ complex_types.each do |c|
+ name = c['Name']
+ props = c.xpath(".//edm:Property", "edm" => edm_ns)
+ methods = props.collect { |p| p['Name'] } # Standard Properties
+ @classes[name] = ClassBuilder.new(name, methods, []).build unless @classes.keys.include?(name)
+ end
- # Helper to loop through a result and create an instance for each entity in the results
- def build_classes_from_result(result)
- doc = Nokogiri::XML(result)
- entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", "atom" => "http://www.w3.org/2005/Atom")
- return entry_to_class(entries[0]) if entries.length == 1
-
- results = []
- entries.each do |entry|
- results << entry_to_class(entry)
- end
- return results
- end
+ entity_types = doc.xpath("//edm:EntityType", "edm" => edm_ns)
+ entity_types.each do |e|
+ name = e['Name']
+ props = e.xpath(".//edm:Property", "edm" => edm_ns)
+ methods = props.collect { |p| p['Name'] } # Standard Properties
+ nprops = e.xpath(".//edm:NavigationProperty", "edm" => edm_ns)
+ nav_props = nprops.collect { |p| p['Name'] } # Standard Properties
+ @classes[name] = ClassBuilder.new(name, methods, nav_props).build unless @classes.keys.include?(name)
+ end
+ end
- # Converts an XML Entry into a class
- def entry_to_class(entry)
- # Retrieve the class name from the fully qualified name (the last string after the last dot)
- klass_name = entry.xpath("./atom:category/@term", "atom" => "http://www.w3.org/2005/Atom").to_s.split('.')[-1]
- return nil if klass_name.empty?
+ # Helper to loop through a result and create an instance for each entity in the results
+ def build_classes_from_result(result)
+ doc = Nokogiri::XML(result)
+ entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", "atom" => "http://www.w3.org/2005/Atom")
+ return entry_to_class(entries[0]) if entries.length == 1
+
+ results = []
+ entries.each do |entry|
+ results << entry_to_class(entry)
+ end
+ return results
+ end
- properties = entry.xpath(".//m:properties/*", { "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" })
-
- klass = @classes[klass_name].new
-
- # Fill metadata
- meta_id = entry.xpath("./atom:id", "atom" => "http://www.w3.org/2005/Atom")[0].content
- klass.send :__metadata=, { :uri => meta_id }
+ # Converts an XML Entry into a class
+ def entry_to_class(entry)
+ # Retrieve the class name from the fully qualified name (the last string after the last dot)
+ klass_name = entry.xpath("./atom:category/@term", "atom" => "http://www.w3.org/2005/Atom").to_s.split('.')[-1]
+ return nil if klass_name.empty?
- # Fill properties
- for prop in properties
- prop_name = prop.name
- klass.send "#{prop_name}=", parse_value(prop)
- end
-
- inline_links = entry.xpath("./atom:link[m:inline]", { "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata", "atom" => "http://www.w3.org/2005/Atom" })
-
- for link in inline_links
- inline_entries = link.xpath(".//atom:entry", "atom" => "http://www.w3.org/2005/Atom")
-
- if inline_entries.length == 1
- property_name = link.attributes['title'].to_s
-
- build_inline_class(klass, inline_entries[0], property_name)
+ properties = entry.xpath(".//m:properties/*", { "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" })
+
+ klass = @classes[klass_name].new
+
+ # Fill metadata
+ meta_id = entry.xpath("./atom:id", "atom" => "http://www.w3.org/2005/Atom")[0].content
+ klass.send :__metadata=, { :uri => meta_id }
+
+ # Fill properties
+ for prop in properties
+ prop_name = prop.name
+ klass.send "#{prop_name}=", parse_value(prop)
+ end
+
+ inline_links = entry.xpath("./atom:link[m:inline]", { "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata", "atom" => "http://www.w3.org/2005/Atom" })
+
+ for link in inline_links
+ inline_entries = link.xpath(".//atom:entry", "atom" => "http://www.w3.org/2005/Atom")
+
+ if inline_entries.length == 1
+ property_name = link.attributes['title'].to_s
+
+ build_inline_class(klass, inline_entries[0], property_name)
else
# TODO: Test handling multiple children
- for inline_entry in inline_entries
- property_name = link.xpath("atom:link[@rel='edit']/@title", "atom" => "http://www.w3.org/2005/Atom")
-
- # Build the class
- inline_klass = entry_to_class(inline_entry)
-
- # Add the property
- klass.send "#{property_name}=", inline_klass
- end
- end
- end
-
- return klass
- end
+ for inline_entry in inline_entries
+ property_name = link.xpath("atom:link[@rel='edit']/@title", "atom" => "http://www.w3.org/2005/Atom")
+
+ # Build the class
+ inline_klass = entry_to_class(inline_entry)
+
+ # Add the property
+ klass.send "#{property_name}=", inline_klass
+ end
+ end
+ end
+
+ return klass
+ end
- def build_query_uri
- "#{@uri}#{@query.query}"
- end
- def build_inline_class(klass, entry, property_name)
- # Build the class
- inline_klass = entry_to_class(entry)
-
- # Add the property
- klass.send "#{property_name}=", inline_klass
- end
- def single_save(operation)
- if operation.kind == "Add"
- save_uri = "#{@uri}/#{operation.klass_name}"
- json_klass = operation.klass.to_json(:type => :add)
- post_result = RestClient.post save_uri, json_klass, :content_type => :json
- return build_classes_from_result(post_result)
- elsif operation.kind == "Update"
- update_uri = operation.klass.send(:__metadata)[:uri]
- json_klass = operation.klass.to_json
- update_result = RestClient.put update_uri, json_klass, :content_type => :json
- return (update_result.code == 204)
- elsif operation.kind == "Delete"
- delete_uri = operation.klass.send(:__metadata)[:uri]
- delete_result = RestClient.delete delete_uri
- return (delete_result.code == 204)
- end
- end
+ def build_query_uri
+ "#{@uri}#{@query.query}"
+ end
+ def build_inline_class(klass, entry, property_name)
+ # Build the class
+ inline_klass = entry_to_class(entry)
+
+ # Add the property
+ klass.send "#{property_name}=", inline_klass
+ end
+ def single_save(operation)
+ if operation.kind == "Add"
+ save_uri = "#{@uri}/#{operation.klass_name}"
+ json_klass = operation.klass.to_json(:type => :add)
+ post_result = RestClient.post save_uri, json_klass, {:content_type => :json}.merge(@http_headers)
+ return build_classes_from_result(post_result)
+ elsif operation.kind == "Update"
+ update_uri = operation.klass.send(:__metadata)[:uri]
+ json_klass = operation.klass.to_json
+ update_result = RestClient.put update_uri, json_klass, {:content_type => :json}.merge(@http_headers)
+ return (update_result.code == 204)
+ elsif operation.kind == "Delete"
+ delete_uri = operation.klass.send(:__metadata)[:uri]
+ delete_result = RestClient.delete delete_uri, @http_headers
+ return (delete_result.code == 204)
+ end
+ end
- # Batch Saves
- def generate_guid
- rand(36**12).to_s(36).insert(4, "-").insert(9, "-")
- end
- def batch_save(operations)
- batch_num = generate_guid
- changeset_num = generate_guid
- batch_uri = "#{@uri}/$batch"
-
- body = build_batch_body(operations, batch_num, changeset_num)
-
- result = RestClient.post batch_uri, body, :content_type => "multipart/mixed; boundary=batch_#{batch_num}"
-
- # TODO: More result validation needs to be done.
- # The result returns HTTP 202 even if there is an error in the batch
- return (result.code == 202)
- end
- def build_batch_body(operations, batch_num, changeset_num)
- # Header
- body = "--batch_#{batch_num}\n"
- body << "Content-Type: multipart/mixed;boundary=changeset_#{changeset_num}\n\n"
+ # Batch Saves
+ def generate_guid
+ rand(36**12).to_s(36).insert(4, "-").insert(9, "-")
+ end
+ def batch_save(operations)
+ batch_num = generate_guid
+ changeset_num = generate_guid
+ batch_uri = "#{@uri}/$batch"
+
+ body = build_batch_body(operations, batch_num, changeset_num)
+
+ result = RestClient.post batch_uri, body, {:content_type => "multipart/mixed; boundary=batch_#{batch_num}"}.merge(@http_headers)
- # Operations
- operations.each do |operation|
- body << build_batch_operation(operation, changeset_num)
- body << "\n"
- end
-
- # Footer
- body << "\n\n--changeset_#{changeset_num}--\n"
- body << "--batch_#{batch_num}--"
-
- return body
- end
- def build_batch_operation(operation, changeset_num)
- accept_headers = "Accept-Charset: utf-8\n"
- accept_headers << "Content-Type: application/json;charset=utf-8\n" unless operation.kind == "Delete"
- accept_headers << "\n"
-
- content = "--changeset_#{changeset_num}\n"
- content << "Content-Type: application/http\n"
- content << "Content-Transfer-Encoding: binary\n\n"
-
- if operation.kind == "Add"
- save_uri = "#{@uri}/#{operation.klass_name}"
- json_klass = operation.klass.to_json(:type => :add)
-
- content << "POST #{save_uri} HTTP/1.1\n"
- content << accept_headers
- content << json_klass
- elsif operation.kind == "Update"
- update_uri = operation.klass.send(:__metadata)[:uri]
- json_klass = operation.klass.to_json
-
- content << "PUT #{update_uri} HTTP/1.1\n"
- content << accept_headers
- content << json_klass
- elsif operation.kind == "Delete"
- delete_uri = operation.klass.send(:__metadata)[:uri]
-
- content << "DELETE #{delete_uri} HTTP/1.1\n"
- content << accept_headers
- end
-
- return content
- end
+ # TODO: More result validation needs to be done.
+ # The result returns HTTP 202 even if there is an error in the batch
+ return (result.code == 202)
+ end
+ def build_batch_body(operations, batch_num, changeset_num)
+ # Header
+ body = "--batch_#{batch_num}\n"
+ body << "Content-Type: multipart/mixed;boundary=changeset_#{changeset_num}\n\n"
- # Complex Types
- def complex_type_to_class(complex_type_xml)
- klass_name = complex_type_xml.attr('type').split('.')[-1]
- klass = @classes[klass_name].new
+ # Operations
+ operations.each do |operation|
+ body << build_batch_operation(operation, changeset_num)
+ body << "\n"
+ end
+
+ # Footer
+ body << "\n\n--changeset_#{changeset_num}--\n"
+ body << "--batch_#{batch_num}--"
+
+ return body
+ end
+ def build_batch_operation(operation, changeset_num)
+ accept_headers = "Accept-Charset: utf-8\n"
+ accept_headers << "Content-Type: application/json;charset=utf-8\n" unless operation.kind == "Delete"
+ accept_headers << "\n"
+
+ content = "--changeset_#{changeset_num}\n"
+ content << "Content-Type: application/http\n"
+ content << "Content-Transfer-Encoding: binary\n\n"
+
+ if operation.kind == "Add"
+ save_uri = "#{@uri}/#{operation.klass_name}"
+ json_klass = operation.klass.to_json(:type => :add)
+
+ content << "POST #{save_uri} HTTP/1.1\n"
+ content << accept_headers
+ content << json_klass
+ elsif operation.kind == "Update"
+ update_uri = operation.klass.send(:__metadata)[:uri]
+ json_klass = operation.klass.to_json
+
+ content << "PUT #{update_uri} HTTP/1.1\n"
+ content << accept_headers
+ content << json_klass
+ elsif operation.kind == "Delete"
+ delete_uri = operation.klass.send(:__metadata)[:uri]
+
+ content << "DELETE #{delete_uri} HTTP/1.1\n"
+ content << accept_headers
+ end
+
+ return content
+ end
- # Fill in the properties
- properties = complex_type_xml.xpath(".//*")
- properties.each do |prop|
- klass.send "#{prop.name}=", parse_value(prop)
- end
+ # Complex Types
+ def complex_type_to_class(complex_type_xml)
+ klass_name = complex_type_xml.attr('type').split('.')[-1]
+ klass = @classes[klass_name].new
- return klass
- end
+ # Fill in the properties
+ properties = complex_type_xml.xpath(".//*")
+ properties.each do |prop|
+ klass.send "#{prop.name}=", parse_value(prop)
+ end
- # Field Converters
- def parse_value(property_xml)
- property_type = property_xml.attr('type')
+ return klass
+ end
- # Handle a nil property type, this is a string
- return property_xml.content if property_type.nil?
+ # Field Converters
+ def parse_value(property_xml)
+ property_type = property_xml.attr('type')
+ property_null = property_xml.attr('null')
- # Handle complex types
- return complex_type_to_class(property_xml) if !property_type.match(/^Edm/)
+ # Handle a nil property type, this is a string
+ return property_xml.content if property_type.nil?
- # Handle integers
- return property_xml.content.to_i if property_type.match(/^Edm.Int/)
+ # Handle anything marked as null
+ return nil if !property_null.nil? && property_null == "true"
- # Handle decimals
- return property_xml.content.to_d if property_type.match(/Edm.Decimal/)
+ # Handle complex types
+ return complex_type_to_class(property_xml) if !property_type.match(/^Edm/)
- # Handle DateTimes
- # return Time.parse(property_xml.content) if property_type.match(/Edm.DateTime/)
- if property_type.match(/Edm.DateTime/)
- sdate = property_xml.content
+ # Handle integers
+ return property_xml.content.to_i if property_type.match(/^Edm.Int/)
- # Assume this is UTC if no timezone is specified
- sdate = sdate + "Z" unless sdate.match(/Z|([+|-]\d{2}:\d{2})$/)
+ # Handle decimals
+ return property_xml.content.to_d if property_type.match(/Edm.Decimal/)
- return Time.parse(sdate)
- end
+ # Handle DateTimes
+ # return Time.parse(property_xml.content) if property_type.match(/Edm.DateTime/)
+ if property_type.match(/Edm.DateTime/)
+ sdate = property_xml.content
- # If we can't parse the value, just return the element's content
- property_xml.content
- end
+ # Assume this is UTC if no timezone is specified
+ sdate = sdate + "Z" unless sdate.match(/Z|([+|-]\d{2}:\d{2})$/)
+
+ return Time.parse(sdate)
+ end
+
+ # If we can't parse the value, just return the element's content
+ property_xml.content
+ end
end
end # module OData
\ No newline at end of file