lib/odata/service.rb
module OData
# Encapsulates the basic details and functionality needed to interact with an
# OData service.
class Service
# The OData Service's URL
attr_reader :service_url
# Options to pass around
attr_reader :options
HTTP_TIMEOUT = 20
METADATA_TIMEOUTS = [20, 60]
# Opens the service based on the requested URL and adds the service to
# {OData::Registry}
#
# @param service_url [String] the URL to the desired OData service
# @param options [Hash] options to pass to the service
# @return [OData::Service] an instance of the service
def initialize(service_url, options = {})
@service_url = service_url
@options = default_options.merge(options)
OData::ServiceRegistry.add(self)
self
end
# Opens the service based on the requested URL and adds the service to
# {OData::Registry}
#
# @param service_url [String] the URL to the desired OData service
# @param options [Hash] options to pass to the service
# @return [OData::Service] an instance of the service
def self.open(service_url, options = {})
Service.new(service_url, options)
end
# Returns user supplied name for service, or its URL
# @return [String]
def name
@name ||= options[:name] || service_url
end
# Returns a list of entities exposed by the service
def entity_types
@entity_types ||= metadata.xpath('//EntityType').collect {|entity| entity.attributes['Name'].value}
end
# Returns a hash of EntitySet names keyed to their respective EntityType name
def entity_sets
@entity_sets ||= Hash[metadata.xpath('//EntityContainer/EntitySet').collect {|entity|
[
entity.attributes['EntityType'].value.gsub("#{namespace}.", ''),
entity.attributes['Name'].value
]
}]
end
# Returns a list of ComplexTypes used by the service
def complex_types
@complex_types ||= metadata.xpath('//ComplexType').collect {|entity| entity.attributes['Name'].value}
end
# Returns the associations defined by the service
# @return [Hash<OData::Association>]
def associations
@associations ||= Hash[metadata.xpath('//Association').collect do |association_definition|
[
association_definition.attributes['Name'].value,
build_association(association_definition)
]
end]
end
# Returns a hash for finding an association through an entity type's defined
# NavigationProperty elements.
# @return [Hash<Hash<OData::Association>>]
def navigation_properties
@navigation_properties ||= Hash[metadata.xpath('//EntityType').collect do |entity_type_def|
entity_type_name = entity_type_def.attributes['Name'].value
[
entity_type_name,
Hash[entity_type_def.xpath('./NavigationProperty').collect do |nav_property_def|
relationship_name = nav_property_def.attributes['Relationship'].value
relationship_name.gsub!(/^#{namespace}\./, '')
[
nav_property_def.attributes['Name'].value,
associations[relationship_name]
]
end]
]
end]
end
# Returns the namespace defined on the service's schema
def namespace
@namespace ||= metadata.xpath('//Schema').first.attributes['Namespace'].value
end
# Returns a more compact inspection of the service object
def inspect
"#<#{self.class.name}:#{self.object_id} name='#{name}' service_url='#{self.service_url}'>"
end
# Retrieves the EntitySet associated with a specific EntityType by name
#
# @param entity_set_name [to_s] the name of the EntitySet desired
# @return [OData::EntitySet] an OData::EntitySet to query
def [](entity_set_name)
xpath_query = "//EntityContainer/EntitySet[@Name='#{entity_set_name}']"
entity_set_node = metadata.xpath(xpath_query).first
raise ArgumentError, "Unknown Entity Set: #{entity_set_name}" if entity_set_node.nil?
container_name = entity_set_node.parent.attributes['Name'].value
entity_type_name = entity_set_node.attributes['EntityType'].value.gsub(/#{namespace}\./, '')
OData::EntitySet.new(name: entity_set_name,
namespace: namespace,
type: entity_type_name.to_s,
service_name: name,
container: container_name)
end
# Execute a request against the service
#
# @param url_chunk [to_s] string to append to service url
# @param additional_options [Hash] options to pass to Typhoeus
# @return [Typhoeus::Response]
def execute(url_chunk, additional_options = {})
request = ::Typhoeus::Request.new(
URI.escape("#{service_url}/#{url_chunk}"),
options[:typhoeus].merge({ method: :get
})
.merge(additional_options)
)
request.run
response = request.response
validate_response(response)
response
end
# Find a specific node in the given result set
#
# @param results [Typhoeus::Response]
# @return [Nokogiri::XML::Element]
def find_node(results, node_name)
document = ::Nokogiri::XML(results.body)
document.remove_namespaces!
document.xpath("//#{node_name}").first
end
# Find entity entries in a result set
#
# @param results [Typhoeus::Response]
# @return [Nokogiri::XML::NodeSet]
def find_entities(results)
document = ::Nokogiri::XML(results.body)
document.remove_namespaces!
document.xpath('//entry')
end
# Get the property type for an entity from metadata.
#
# @param entity_name [to_s] the name of the relevant entity
# @param property_name [to_s] the property name needed
# @return [String] the name of the property's type
def get_property_type(entity_name, property_name)
metadata.xpath("//EntityType[@Name='#{entity_name}']/Property[@Name='#{property_name}']").first.attributes['Type'].value
end
# Get the property used as the title for an entity from metadata.
#
# @param entity_name [to_s] the name of the relevant entity
# @return [String] the name of the property used as the entity title
def get_title_property_name(entity_name)
node = metadata.xpath("//EntityType[@Name='#{entity_name}']/Property[@FC_TargetPath='SyndicationTitle']").first
node.nil? ? nil : node.attributes['Name'].value
end
# Get the property used as the summary for an entity from metadata.
#
# @param entity_name [to_s] the name of the relevant entity
# @return [String] the name of the property used as the entity summary
def get_summary_property_name(entity_name)
metadata.xpath("//EntityType[@Name='#{entity_name}']/Property[@FC_TargetPath='SyndicationSummary']").first.attributes['Name'].value
rescue NoMethodError
nil
end
# Get the primary key for the supplied Entity.
#
# @param entity_name [to_s]
# @return [String]
def primary_key_for(entity_name)
metadata.xpath("//EntityType[@Name='#{entity_name}']/Key/PropertyRef").first.attributes['Name'].value
end
# Get the list of properties and their various options for the supplied
# Entity name.
# @param entity_name [to_s]
# @return [Hash]
# @api private
def properties_for_entity(entity_name)
type_definition = metadata.xpath("//EntityType[@Name='#{entity_name}']").first
raise ArgumentError, "Unknown EntityType: #{entity_name}" if type_definition.nil?
properties_to_return = {}
type_definition.xpath('./Property').each do |property_xml|
property_name, property = process_property_from_xml(property_xml)
properties_to_return[property_name] = property
end
properties_to_return
end
# Get list of properties and their various options for the supplied
# ComplexType name.
# @param type_name [to_s]
# @return [Hash]
# @api private
def properties_for_complex_type(type_name)
type_definition = metadata.xpath("//ComplexType[@Name='#{type_name}']").first
raise ArgumentError, "Unknown ComplexType: #{type_name}" if type_definition.nil?
properties_to_return = {}
type_definition.xpath('./Property').each do |property_xml|
property_name, property = process_property_from_xml(property_xml)
properties_to_return[property_name] = property
end
properties_to_return
end
def logger
@logger ||= defined?(Rails) ? Rails.logger : Logger.new(STDOUT)
end
def logger=(custom_logger)
@logger = custom_logger
end
private
def default_options
{
typhoeus: {
headers: { 'DataServiceVersion' => '3.0' },
timeout: HTTP_TIMEOUT
}
}
end
def metadata
@metadata ||= lambda {
read_metadata
}.call
end
def read_metadata
response = nil
# From file, good for debugging
if options[:metadata_file]
data = File.read(options[:metadata_file])
::Nokogiri::XML(data).remove_namespaces!
else # From a URL
METADATA_TIMEOUTS.each do |timeout|
response = ::Typhoeus::Request.get(URI.escape("#{service_url}/$metadata"),
options[:typhoeus].merge(timeout: timeout))
break unless response.timed_out?
end
raise "Metadata Timeout" if response.timed_out?
validate_response(response)
::Nokogiri::XML(response.body).remove_namespaces!
end
end
def validate_response(response)
raise "Bad Request. #{error_message(response)}" if response.code == 400
raise "Access Denied" if response.code == 401
raise "Forbidden" if response.code == 403
raise "Invalid URL" if [0,404].include?(response.code)
raise "Method Not Allowed" if response.code == 405
raise "Not Acceptable" if response.code == 406
raise "Request Entity Too Large" if response.code == 413
raise "Internal Server Error" if response.code == 500
raise "Service Unavailable" if response.code == 503
end
def error_message(response)
xml = ::Nokogiri::XML(response.body).remove_namespaces!
xml.xpath('//error/message').first.andand.text
end
def process_property_from_xml(property_xml)
property_name = property_xml.attributes['Name'].value
value_type = property_xml.attributes['Type'].value
property_options = {}
klass = ::OData::PropertyRegistry[value_type]
if klass.nil? && value_type =~ /^#{namespace}\./
type_name = value_type.gsub(/^#{namespace}\./, '')
property = ::OData::ComplexType.new(name: type_name, service: self)
elsif klass.nil?
raise RuntimeError, "Unknown property type: #{value_type}"
else
property_options[:allows_nil] = false if property_xml.attributes['Nullable'] == 'false'
property = klass.new(property_name, nil, property_options)
end
return [property_name, property]
end
def build_association(association_definition)
options = {
name: association_definition.attributes['Name'].value,
ends: build_association_ends(association_definition.xpath('./End'))
}
::OData::Association.new(options)
end
def build_association_ends(end_definitions)
end_definitions.collect do |end_definition|
options = {
entity_type: end_definition.attributes['Type'].value,
multiplicity: end_definition.attributes['Multiplicity'].value
}
::OData::Association::End.new(options)
end
end
end
end