Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Added basic error handling for service related errors

  • Loading branch information...
commit 9d0ea700f277086dff72ace8c329a55a99b8e22c 1 parent 5957a3f
@visoft authored
View
13 features/error_handling.feature
@@ -0,0 +1,13 @@
+Feature: Error handling
+ In order to assist debugging
+ As a user
+ I want more debug information when an error occurs communicating with the server
+
+Background:
+ Given a HTTP ODataService exists
+ And blueprints exist for the service
+
+Scenario: Violate a data type conversion (empty string to decimal)
+ Given I call "AddToProducts" on the service with a new "Product" object with Price: ""
+ When I save changes it should throw an exception with message containing "HTTP Error 400"
+
View
40 features/step_definitions/service_steps.rb
@@ -30,11 +30,11 @@
@service = OData::Service.new(BASICAUTH_URL, { :username => username, :password => password })
end
-Given /^a HTTP BasicAuth ODataService exists using username "([^\"]*)" and password "([^\"]*)" it should throw an exception with message "([^\"]*)"$/ do |username, password, msg|
+Given /^a HTTP BasicAuth ODataService exists using username "([^\"]*)" and password "([^\"]*)" it should throw an exception with message "([^\"]*)"$/ do |username, password, msg|
lambda { @service = OData::Service.new(BASICAUTH_URL, { :username => username, :password => password }) }.should raise_error(msg)
end
-Given /^a HTTP BasicAuth ODataService exists it should throw an exception with message containing "([^\"]*)"$/ do |msg|
+Given /^a HTTP BasicAuth ODataService exists it should throw an exception with message containing "([^\"]*)"$/ do |msg|
lambda { @service = OData::Service.new(BASICAUTH_URL) }.should raise_error(/#{msg}.*/)
end
@@ -42,7 +42,7 @@
lambda { @service = OData::Service.new(HTTPS_BASICAUTH_URL) }.should raise_error(/#{msg}.*/)
end
-Given /^a HTTP BasicAuth ODataService exists it should throw an exception with message "([^\"]*)"$/ do |msg|
+Given /^a HTTP BasicAuth ODataService exists it should throw an exception with message "([^\"]*)"$/ do |msg|
lambda { @service = OData::Service.new(BASICAUTH_URL) }.should raise_error(msg)
end
@@ -55,7 +55,7 @@
end
Then /^the result should be "([^\"]*)"$/ do |result|
- @service_result.should eq result
+ @service_result.should eq result
end
Then /^the integer result should be ([^\"]*)$/ do |result|
@@ -127,13 +127,13 @@
end
Then /^the method "([^\"]*)" on the result should be of type "([^\"]*)"$/ do |method, type|
- result = @service_result.send(method.to_sym)
+ result = @service_result.send(method.to_sym)
result.class.to_s.should eq type
end
Given /^I call "([^\"]*)" on the service with a new "([^\"]*)" object(?: with (.*))?$/ do |method, object, fields|
fields_hash = parse_fields_string(fields)
-
+
obj = object.constantize.send(:make, fields_hash)
@service.send(method.to_sym, obj)
end
@@ -172,6 +172,10 @@
lambda { @service.send(method.to_sym, obj) }.should raise_error(msg)
end
+When /^I save changes it should throw an exception with message containing "([^"]*)"$/ do |msg|
+ lambda { @service.save_changes }.should raise_error(/#{msg}.*/)
+end
+
Then /^no "([^\"]*)" should exist$/ do |collection|
@service.send(collection)
results = @service.execute
@@ -180,19 +184,19 @@
Then /^the primitive results should be:$/ do |table|
# table is a Cucumber::Ast::Table
- values = table.hashes
+ values = table.hashes
result_table = Cucumber::Ast::Table.new(values)
table.diff!(result_table)
end
Then /^the result should be:$/ do |table|
# table is a Cucumber::Ast::Table
-
+
fields = table.hashes[0].keys
-
+
# Build an array of hashes so that we can compare tables
results = []
-
+
@service_result.each do |result|
obj_hash = Hash.new
fields.each do |field|
@@ -200,20 +204,20 @@
end
results << obj_hash
end
-
+
result_table = Cucumber::Ast::Table.new(results)
-
+
table.diff!(result_table)
end
Then /^the save result should be:$/ do |table|
# table is a Cucumber::Ast::Table
-
+
fields = table.hashes[0].keys
-
+
# Build an array of hashes so that we can compare tables
results = []
-
+
@saved_result.each do |result|
obj_hash = Hash.new
fields.each do |field|
@@ -221,10 +225,10 @@
end
results << obj_hash
end
-
+
result_table = Cucumber::Ast::Table.new(results)
-
- table.diff!(result_table)
+
+ table.diff!(result_table)
end
Then /^a class named "([^\"]*)" should exist$/ do |klass_name|
View
159 lib/ruby_odata/service.rb
@@ -1,5 +1,5 @@
module OData
-
+
class Service
attr_reader :classes, :class_metadata, :options, :collections, :edmx, :function_imports
# Creates a new instance of the Service class
@@ -79,17 +79,21 @@ def save_changes
result = nil
- if @save_operations.length == 1
- result = single_save(@save_operations[0])
- else
- result = batch_save(@save_operations)
- end
+ begin
+ 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
+ # TODO: We should probably perform a check here
+ # to make sure everything worked before clearing it out
+ @save_operations.clear
- return result
+ return result
+ rescue Exception => e
+ handle_exception(e)
+ end
end
# Performs query operations (Read) against the server, returns an array of record instances.
@@ -179,16 +183,16 @@ def default_instance_vars!
@has_partial = false
@next_uri = nil
end
-
+
def set_namespaces
@edmx = Nokogiri::XML(RestClient::Resource.new(build_metadata_uri, @rest_options).get)
- @ds_namespaces = {
+ @ds_namespaces = {
"m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
"edmx" => "http://schemas.microsoft.com/ado/2007/06/edmx",
"ds" => "http://schemas.microsoft.com/ado/2007/08/dataservices",
"atom" => "http://www.w3.org/2005/Atom"
}
-
+
# Get the edm namespace from the edmx
edm_ns = @edmx.xpath("edmx:Edmx/edmx:DataServices/*", @namespaces).first.namespaces['xmlns'].to_s
@ds_namespaces.merge! "edm" => edm_ns
@@ -232,7 +236,7 @@ def build_collections_and_classes
entity_type = c["EntityType"]
@collections[c["Name"]] = { :edmx_type => entity_type, :type => convert_to_local_type(entity_type) }
end
-
+
build_function_imports
end
@@ -258,9 +262,9 @@ def build_function_imports
parameters[p["Name"]] = p["Type"]
end
end
- @function_imports[f["Name"]] = {
- :http_method => http_method,
- :return_type => return_type,
+ @function_imports[f["Name"]] = {
+ :http_method => http_method,
+ :return_type => return_type,
:inner_return_type => inner_return_type,
:parameters => parameters }
end
@@ -294,7 +298,7 @@ def build_property_metadata(props)
end
metadata
end
-
+
# Handle parsing of OData Atom result and return an array of Entry classes
def handle_collection_result(result)
results = build_classes_from_result(result)
@@ -303,7 +307,18 @@ def handle_collection_result(result)
end
results
end
-
+
+ # Handles errors from the OData service
+ def handle_exception(e)
+ raise e unless e.response
+
+ code = e.http_code
+ error = Nokogiri::XML(e.response)
+
+ message = error.xpath("m:error/m:message", @ds_namespaces).first.content
+ raise "HTTP Error #{code}: #{message}"
+ end
+
# Loops through the standard properties (non-navigation) for a given class and returns the appropriate list of methods
def collect_properties(klass_name, element, doc)
props = element.xpath(".//edm:Property", @ds_namespaces)
@@ -318,7 +333,7 @@ def collect_properties(klass_name, element, doc)
end
methods
end
-
+
# Similar to +collect_properties+, but handles the navigation properties
def collect_navigation_properties(klass_name, element, doc)
nav_props = element.xpath(".//edm:NavigationProperty", @ds_namespaces)
@@ -329,14 +344,14 @@ def collect_navigation_properties(klass_name, element, doc)
# 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)
-
+
is_links = doc.at_xpath("/ds:links", @ds_namespaces)
return parse_link_results(doc) if is_links
-
+
entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
-
+
extract_partial(doc)
-
+
results = []
entries.each do |entry|
results << entry_to_class(entry)
@@ -348,24 +363,24 @@ def build_classes_from_result(result)
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", @ds_namespaces).to_s.split('.')[-1]
-
+
# Is the category missing? See if there is a title that we can use to build the class
if klass_name.nil?
title = entry.xpath("./atom:title", @ds_namespaces).first
return nil if title.nil?
klass_name = title.content.to_s
end
-
+
return nil if klass_name.nil?
- # If we are working against a child (inline) entry, we need to use the more generic xpath because a child entry WILL
+ # If we are working against a child (inline) entry, we need to use the more generic xpath because a child entry WILL
# have properties that are ancestors of m:inline. Check if there is an m:inline child to determine the xpath query to use
has_inline = entry.xpath(".//m:inline", @ds_namespaces).any?
properties_xpath = has_inline ? ".//m:properties[not(ancestor::m:inline)]/*" : ".//m:properties/*"
properties = entry.xpath(properties_xpath, @ds_namespaces)
klass = @classes[qualify_class_name(klass_name)].new
-
+
# Fill metadata
meta_id = entry.xpath("./atom:id", @ds_namespaces)[0].content
klass.send :__metadata=, { :uri => meta_id }
@@ -375,7 +390,7 @@ def entry_to_class(entry)
prop_name = prop.name
klass.send "#{prop_name}=", parse_value(prop)
end
-
+
# Fill properties represented outside of the properties collection
@class_metadata[qualify_class_name(klass_name)].select { |k,v| v.fc_keep_in_content == false }.each do |k, meta|
if meta.fc_target_path == "SyndicationTitle"
@@ -386,13 +401,13 @@ def entry_to_class(entry)
klass.send "#{meta.name}=", summary.content
end
end
-
+
inline_links = entry.xpath("./atom:link[m:inline]", @ds_namespaces)
-
+
for link in inline_links
inline_entries = link.xpath(".//atom:entry", @ds_namespaces)
- # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
+ # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
property_name = link.attributes['title'].to_s
if inline_entries.length == 1 && singular?(property_name)
inline_klass = build_inline_class(klass, inline_entries[0], property_name)
@@ -412,17 +427,17 @@ def entry_to_class(entry)
klass.send "#{property_name}=", inline_classes
end
end
-
+
klass
end
-
+
# Tests for and extracts the next href of a partial
def extract_partial(doc)
next_links = doc.xpath('//atom:link[@rel="next"]', @ds_namespaces)
@has_partial = next_links.any?
@next_uri = next_links[0]['href'] if @has_partial
end
-
+
def handle_partial
if @next_uri
result = RestClient::Resource.new(@next_uri, @rest_options).get
@@ -430,7 +445,7 @@ def handle_partial
end
results
end
-
+
# Handle link results
def parse_link_results(doc)
uris = doc.xpath("/ds:links/ds:uri", @ds_namespaces)
@@ -440,7 +455,7 @@ def parse_link_results(doc)
results << URI.parse(link)
end
results
- end
+ end
# Build URIs
def build_metadata_uri
@@ -470,7 +485,7 @@ def build_resource_uri(operation)
def build_batch_uri
uri = "#{@uri}/$batch"
uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
- uri
+ uri
end
def build_load_property_uri(obj, property)
uri = obj.__metadata[:uri]
@@ -483,11 +498,11 @@ def build_function_import_uri(name, params)
uri << "?#{params.to_query}" unless params.empty?
uri
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
@@ -543,20 +558,20 @@ def single_save(operation)
def generate_guid
rand(36**12).to_s(36).insert(4, "-").insert(9, "-")
end
- def batch_save(operations)
+ def batch_save(operations)
batch_num = generate_guid
changeset_num = generate_guid
batch_uri = build_batch_uri
-
+
body = build_batch_body(operations, batch_num, changeset_num)
result = RestClient::Resource.new( batch_uri, @rest_options).post body, {:content_type => "multipart/mixed; boundary=batch_#{batch_num}"}
- # TODO: More result validation needs to be done.
+ # 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
+ # Header
body = "--batch_#{batch_num}\n"
body << "Content-Type: multipart/mixed;boundary=changeset_#{changeset_num}\n\n"
@@ -565,51 +580,51 @@ def build_batch_body(operations, batch_num, changeset_num)
body << build_batch_operation(operation, changeset_num)
body << "\n"
end
-
- # Footer
+
+ # 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"
+
+ 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
elsif
save_uri = build_add_link_uri(operation)
json_klass = operation.child_klass.to_json(:type => :link)
-
+
content << "POST #{save_uri} HTTP/1.1\n"
content << accept_headers
content << json_klass
link_child_to_parent(operation)
end
-
+
return content
end
@@ -628,24 +643,24 @@ def complex_type_to_class(complex_type_xml)
end
# Field Converters
-
+
# Handles parsing datetimes from a string
def parse_date(sdate)
# Assume this is UTC if no timezone is specified
sdate = sdate + "Z" unless sdate.match(/Z|([+|-]\d{2}:\d{2})$/)
-
+
# This is to handle older versions of Ruby (e.g. ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mingw32])
# See http://makandra.com/notes/1017-maximum-representable-value-for-a-ruby-time-object
# In recent versions of Ruby, Time has a much larger range
begin
- result = Time.parse(sdate)
+ result = Time.parse(sdate)
rescue ArgumentError
result = DateTime.parse(sdate)
end
-
+
return result
end
-
+
# Parses a value into the proper type based on an xml property element
def parse_value(property_xml)
property_type = property_xml.attr('type')
@@ -673,7 +688,7 @@ def parse_value(property_xml)
# If we can't parse the value, just return the element's content
property_xml.content
end
-
+
# Parses a value into the proper type based on a specified return type
def parse_primative_type(value, return_type)
return value.to_i if return_type == Fixnum
@@ -681,7 +696,7 @@ def parse_primative_type(value, return_type)
return parse_date(value.to_s) if return_type == Time
return value.to_s
end
-
+
# Converts an edm type (string) to a ruby type
def edm_to_ruby_type(edm_type)
return String if edm_type =~ /Edm.String/
@@ -690,30 +705,30 @@ def edm_to_ruby_type(edm_type)
return Time if edm_type =~ /Edm.DateTime/
return String
end
-
+
# Method Missing Handlers
-
+
# Executes an import function
def execute_import_function(name, *args)
func = @function_imports[name]
-
+
# Check the args making sure that more weren't passed in than the function needs
param_count = func[:parameters].nil? ? 0 : func[:parameters].count
arg_count = args.nil? ? 0 : args[0].count
if arg_count > param_count
raise ArgumentError, "wrong number of arguments (#{arg_count} for #{param_count})"
end
-
+
# Convert the parameters to a hash
params = {}
func[:parameters].keys.each_with_index { |key, i| params[key] = args[0][i] } unless func[:parameters].nil?
-
+
function_uri = build_function_import_uri(name, params)
result = RestClient::Resource.new(function_uri, @rest_options).send(func[:http_method].downcase, {})
-
+
# Is this a 204 (No content) result?
return true if result.code == 204
-
+
# No? Then we need to parse the results. There are 4 kinds...
if func[:return_type] == Array
# a collection of entites
@@ -726,19 +741,19 @@ def execute_import_function(name, *args)
end
return results
end
-
+
# a single entity
if @classes.include?(func[:return_type].to_s)
entry = Nokogiri::XML(result).xpath("atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
return entry_to_class(entry)
end
-
+
# or a single native type
unless func[:return_type].nil?
e = Nokogiri::XML(result).xpath("/*").first
return parse_primative_type(e.content, func[:return_type])
end
-
+
# Nothing could be parsed, so just return if we got a 200 or not
return (result.code == 200)
end
Please sign in to comment.
Something went wrong with that request. Please try again.