-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
git-svn-id: http://quickbooks.rubyforge.org/svn@15 0eb1a872-461d-0410-a379-984e9e9c21cf
- Loading branch information
dcparker
committed
Mar 17, 2008
1 parent
8ea5dae
commit 83415a5
Showing
17 changed files
with
696 additions
and
529 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
require 'qbxml/request' | ||
require 'qbxml/response' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
require 'qbxml/support/core_ext' | ||
gem 'builder', '=2.1.2' | ||
require 'builder' | ||
|
||
module Qbxml | ||
VERSION = '6.0' | ||
|
||
# module RequestSetArrayExt | ||
# end | ||
|
||
class RequestSet | ||
include Enumerable | ||
def set | ||
unless @set.is_a?(Array) | ||
@set = [] | ||
# @set.extend(Qbxml::RequestSetArrayExt) | ||
end | ||
@set | ||
end | ||
delegate_methods [:each, :length, :first, :last, :[], :map, :join] => :set | ||
def <<(qbxml_request) | ||
if qbxml_request.is_a?(Qbxml::Request) | ||
set << qbxml_request | ||
elsif qbxml_request.respond_to?(:each) | ||
qbxml_request.each do |request| | ||
self << request | ||
end | ||
else | ||
raise ArgumentError, "Cannot add object of type #{qbxml_request.class.name} to a Qbxml::RequestSet" | ||
end | ||
end | ||
|
||
def initialize(*requests) | ||
self << requests | ||
end | ||
|
||
def to_xml | ||
pre = <<-thequickbooks_qbxmlrequestsetxml | ||
<?xml version="1.0" ?> | ||
<?qbxml version="#{Qbxml::VERSION}" ?> | ||
<QBXML> | ||
<QBXMLMsgsRq onError="stopOnError"> | ||
thequickbooks_qbxmlrequestsetxml | ||
requests = map {|x| x.to_xml(false)}.join | ||
post = <<-thequickbooks_qbxmlrequestsetxml | ||
</QBXMLMsgsRq> | ||
</QBXML> | ||
thequickbooks_qbxmlrequestsetxml | ||
# puts pre + requests + post | ||
pre + requests + post | ||
end | ||
end | ||
|
||
class Request | ||
# 1. List queries (:query) | ||
# Request.new(object, :query) # <= will return the record matching the object supplied (automatically searches by list_id or txn_id) | ||
# Request.new(Customer, :query, :limit => 1) # <= will return the first customer | ||
# Request.new(Customer, :query, /some match/) # <= will return all customers matching the regexp | ||
# 2. Object-specific transaction query (:transaction) | ||
# Request.new(Transaction, :query) # <= will return the transaction matching the object supplied | ||
# 3. Mod requests (:mod) | ||
# Request.new(object, :mod) # <= will update the object | ||
# 4. Delete requests (:delete) | ||
# Request.new(object, :delete) # <= will delete the object | ||
# We want the attributes of the object when we are updating, but no other time. | ||
def initialize(object, type, options_or_regexp={}) | ||
options = options_or_regexp.is_a?(Regexp) ? {:matches => options_or_regexp} : options_or_regexp | ||
@type = type | ||
raise ArgumentError, "Qbxml::Requests can only be of one of the following types: :query, :transaction, :any_transaction, :mod, :add, :delete, or :report" unless @type.is_one_of?(:query, :transaction, :any_transaction, :mod, :report, :add, :delete) | ||
@klass = object.is_a?(Class) ? object : object.class | ||
@object = object | ||
@options = options | ||
|
||
# Return only specific properties: Request.new(Customer, :query, :only => [:list_id, :full_name]); Quickbooks::Customer.first(:only => :list_id) | ||
@ret_elements = @options.delete(:only).to_a.only(@klass.properties).order!(@klass.properties).stringify_values.camelize_values!(Quickbooks::CAMELIZE_EXCEPTIONS) if @options.has_key?(:only) | ||
|
||
# Includes only valid filters + aliases for valid filters, then transforms aliased filters to real filters, then camelizes keys to prepare for writing to XML, lastly orders the keys to a valid filter order. | ||
# You may optionally have ListID OR FullName OR ( MaxReturned AND ActiveStatus AND FromModifiedDate AND ToModifiedDate AND ( NameFilter OR NameRangeFilter ) ) | ||
@filters = options.stringify_keys.only(@klass.valid_filters + @klass.filter_aliases.keys).transform_keys!(@klass.filter_aliases).camelize_keys!(Quickbooks::CAMELIZE_EXCEPTIONS).order!(@klass.camelized_valid_filters) | ||
|
||
# Complain if: | ||
# 1) type is :mod or :delete, and object supplied is not a valid model | ||
raise ArgumentError, "A Quickbooks record object must be supplied to perform an add, mod or del action" if @type.is_one_of?(:add, :mod, :delete) && !@object.is_a?(Quickbooks::Base) | ||
end | ||
|
||
def self.next_request_id | ||
@request_id ||= 0 | ||
@request_id += 1 | ||
end | ||
|
||
# This is where the magic happens to convert a request object into xml worthy of quickbooks. | ||
def to_xml(as_set=true) | ||
return (RequestSet.new(self)).to_xml if as_set # Simple call yields xml as a single request in a request set. However, if the xml for the lone request is required, pass false. | ||
req = Builder::XmlMarkup.new(:indent => 2) | ||
request_root, container = case | ||
when @type.is_one_of?(:query) | ||
["#{@klass.class_leaf_name}QueryRq", nil] | ||
when @type == :add | ||
["#{@klass.class_leaf_name}AddRq", "#{@klass.class_leaf_name}Add"] | ||
when @type == :mod | ||
["#{@klass.class_leaf_name}ModRq", "#{@klass.class_leaf_name}Mod"] | ||
when @type == :delete | ||
["#{@klass.ListOrTxn}DelRq", nil] | ||
else | ||
raise RuntimeError, "Could not convert this request to qbxml!\n#{self.inspect}" | ||
end | ||
inner_stuff = lambda { | ||
deep_tag = lambda {|k,v| | ||
if v.is_a?(Hash) | ||
if k == '' | ||
v.each { |k,v| | ||
deep_tag.call(k,v) | ||
} | ||
else | ||
req.tag!(k.camelize) { v.each { |k,v| deep_tag.call(k,v) } } | ||
end | ||
else | ||
req.tag!(k.camelize,uncast(v)) | ||
end | ||
} | ||
|
||
# Add the specific elements for the respective request type | ||
if @type.is_one_of?(:add, :mod) | ||
if @type == :mod | ||
# First the ObjectId: | ||
req.tag!(@klass.ListOrTxn + 'ID', @object.send("#{@klass.ListOrTxn}Id".underscore)) | ||
# Second the EditSequence | ||
req.tag!('EditSequence', @object.send(:edit_sequence)) | ||
end | ||
# Then, all the dirty_attributes | ||
deep_tag.call('',@object.to_dirty_hash) # (this is an hash statically ordered to the model's qbxml attribute order) | ||
elsif @type == :query && @object.class == @klass | ||
# Sent an instance object for a query - we should include the ListId/TxnId (then other filters?) | ||
req.tag!(@klass.ListOrTxn + 'ID', @object.send("#{@klass.ListOrTxn}Id".underscore)) | ||
deep_tag.call('', @filters) | ||
elsif @type == :delete | ||
req.tag!(@klass.ListOrTxn + 'DelType', @klass.class_leaf_name) | ||
req.tag!(@klass.ListOrTxn + 'ID', @object.send("#{@klass.ListOrTxn}Id".underscore)) | ||
else | ||
# just filters | ||
deep_tag.call('', @filters) | ||
end | ||
# Lastly, specify the fields to return, if desired | ||
@ret_elements.each { |r| req.tag!('IncludeRetElement', r) } if !@ret_elements.blank? | ||
} | ||
req.tag!(request_root, :requestID => self.class.next_request_id) { | ||
if container | ||
req.tag!(container) { | ||
inner_stuff.call | ||
} | ||
else | ||
inner_stuff.call | ||
end | ||
} | ||
# puts req.target! | ||
req.target! | ||
end | ||
|
||
private | ||
def uncast(v) | ||
v.is_a?(Time) ? v.xmlschema : v | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
require 'qbxml/support/core_ext' | ||
gem 'formattedstring' | ||
require 'formatted_string' | ||
|
||
module Qbxml | ||
# module ResponseSetArrayExt | ||
# end | ||
|
||
class ResponseSet | ||
include Enumerable | ||
def set | ||
unless @set.is_a?(Array) | ||
@set = [] | ||
# @set.extend(Qbxml::ResponseSetArrayExt) | ||
end | ||
@set | ||
end | ||
delegate_methods [:each, :length, :first, :last, :[], :map, :join] => :set | ||
def <<(qbxml_response) | ||
if qbxml_response.is_a?(Qbxml::Response) | ||
set << qbxml_response | ||
elsif qbxml_response.respond_to?(:each) | ||
qbxml_response.each do |response| | ||
self << response | ||
end | ||
else | ||
raise ArgumentError, "Cannot add object of type #{qbxml_response.class.name} to a Qbxml::ResponseSet" | ||
end | ||
end | ||
|
||
def initialize(xml_or_hash) | ||
if(xml_or_hash.is_a?(Hash)) | ||
self.append_from_hash(xml_or_hash) | ||
elsif(xml_or_hash.is_a?(String)) | ||
self.append_from_xml(xml_or_hash) | ||
else | ||
raise ArgumentError, "Qbxml::ResponseSet must be initialized with either a Hash or an xml-formatted String." | ||
end | ||
end | ||
|
||
def append_from_xml(xml) | ||
self.append_from_hash(xml.formatted(:xml).to_hash) | ||
end | ||
def append_from_hash(hsh) | ||
to_append = [] | ||
hsh = hsh['QBXML'] if hsh.has_key?('QBXML') | ||
hsh = hsh['QBXMLMsgsRs'] if hsh.has_key?('QBXMLMsgsRs') | ||
# responses will contain one or more keys. | ||
hsh.each_key do |name| | ||
# response_type is either a single response object, or an array of response objects. Force it into an array: | ||
responses = hsh[name].is_a?(Array) ? hsh[name] : [hsh[name]] | ||
responses.each { |response| to_append << Response.new(name => response) } | ||
end | ||
self << to_append | ||
end | ||
|
||
class << self | ||
def from_xml(xml) | ||
new.append_from_xml(xml) | ||
end | ||
def from_hash(hsh) | ||
new.append_from_hash(hsh) | ||
end | ||
end | ||
end | ||
|
||
class Response | ||
attr_accessor :response_type, :status, :message, :severity, :ret_items | ||
# For Development purposes: | ||
attr_accessor :raw_response | ||
|
||
def initialize(xml_or_hash) | ||
if(xml_or_hash.is_a?(Hash)) | ||
self.import_from_hash(xml_or_hash) | ||
elsif(xml_or_hash.is_a?(String)) | ||
self.import_from_xml(xml_or_hash) | ||
else | ||
raise ArgumentError, "Qbxml::ResponseSet must be initialized with either a Hash or an xml-formatted String." | ||
end | ||
end | ||
|
||
def import_from_xml(xml) | ||
self.import_from_hash(xml.formatted(:xml).to_hash) | ||
self | ||
end | ||
def import_from_hash(hsh) | ||
raise ArgumentError, "Hash passed to Qbxml::Response.from_hash must contain only one top-level key" unless hsh.keys.length == 1 | ||
name = hsh.keys.first | ||
# for development | ||
self.raw_response = hsh | ||
# * * * * | ||
hsh = hsh[name] | ||
|
||
self.status = hsh['statusCode'].to_i | ||
self.severity = hsh['statusSeverity'] | ||
self.message = hsh['statusMessage'] | ||
# if self.status == 0 # Status is good, proceed with eating the request. | ||
if m = name.match(/^(List|Txn)Del(etedQuery)?Rs$/) | ||
# (List|Txn)DelRs, or (List|Txn)DeletedQueryRs - both return just a few attributes, like ListID / TxnID and TimeDeleted | ||
self.response_type = hsh.delete(m[1]+'DelType') | ||
# self.ret_items = ResponseObject.new(self.response_type, hsh.dup) | ||
self.ret_items = hsh.dup | ||
elsif m = name.match(/^([A-Za-z][a-z]+)(Query|Mod|Add)Rs$/) | ||
self.response_type = m[1] | ||
self.ret_items = hsh[self.response_type+'Ret'] | ||
else | ||
raise "Could not read this response:\n#{self.raw_response.inspect}" | ||
end | ||
# else # Status is bad. | ||
|
||
# end | ||
# puts self.inspect | ||
self | ||
end | ||
|
||
|
||
class << self | ||
def from_xml(xml) | ||
new.import_from_xml(xml) | ||
end | ||
def from_hash(hsh) | ||
new.import_from_hash(hsh) | ||
end | ||
end | ||
|
||
def instantiatable? # Just checks to see if it can create an object of this type, and if there is actually data to instantiate | ||
self.response_type ? (Quickbooks.const_defined?(self.response_type) && self.ret_items.is_a?(Hash) || (self.ret_items.is_a?(Array) && self.ret_items[0].is_a?(Hash))) : false | ||
end | ||
def success? | ||
self.status == 0 | ||
end | ||
def error? | ||
!success? | ||
end | ||
|
||
def instantiate(reinstantiate=nil) | ||
objs = [] | ||
(self.ret_items.is_a?(Array) ? self.ret_items : [self.ret_items]).each do |ret_item| | ||
obj = reinstantiate if reinstantiate | ||
if instantiatable? | ||
if self.status == 0 | ||
if obj.nil? | ||
obj = "Quickbooks::#{self.response_type}".constantize.instantiate(ret_item) | ||
else | ||
obj.class.instantiate(obj, ret_item) | ||
end | ||
else | ||
# Instantiatable, but some error status. For any error status | ||
if obj.nil? # Must be a new object. Just as well, create a new object WITHOUT any original_attributes. | ||
obj = "Quickbooks::#{self.response_type}".constantize.new(ret_item) | ||
else # An existing object. Must be an update or delete request. Update object's original_attributes. | ||
updated = [] | ||
ret_item.each do |k,v| | ||
k = k.underscore | ||
if obj.original_values[k] != v | ||
updated << k | ||
if obj.class.instance_variable_get('@object_properties').has_key?(k.to_sym) | ||
obj.original_values[k] = obj.class.instance_variable_get('@object_properties')[k.to_sym].new(v) | ||
else | ||
obj.original_values[k] = v | ||
end | ||
end | ||
end | ||
# Update object's 'read-only' attributes (as well as in the original_values): | ||
obj.class.read_only.stringify_values.camelize_values.each do |k| | ||
obj.send(k.underscore + '=', ret_item[k]) if ret_item.has_key?(k) && obj.respond_to?(k.underscore + '=') | ||
end | ||
self.message = self.message + " Other properties were out of date: [" + updated.join(', ') + '].' | ||
end | ||
obj.errors << {:code => self.status, :message => self.message, :response => self} | ||
end | ||
else | ||
if self.status == 0 | ||
obj.errors << {:code => nil, :message => "Cannot instantiate object of type #{self.response_type}!", :response => self} | ||
else | ||
obj.errors << {:code => self.status, :message => self.message, :response => self} | ||
end | ||
end | ||
obj.response_log << self unless obj.nil? | ||
objs << obj | ||
end | ||
objs.length > 1 ? objs : objs[0] # Single or nil if not more than one. | ||
end | ||
end | ||
end |
Oops, something went wrong.