require "rubygems" require "iconv" require "hpricot" # Apricot eats Gorilla is a SOAP communication helper. # # It translates between SOAP messages (XML) and Ruby Hashes while offering some # helpful methods for working with SOAP webservices. Apricot eats Gorilla was # initially based on CobraVsMongoose but uses Hpricot instead of REXML. class ApricotEatsGorilla class << self # Class methods # Flag to enable sorting of Hash keys. attr_accessor :sort_keys # Flag to disable conversion of tag names to lowerCamelCase. attr_accessor :disable_tag_names_to_lower_camel_case # Flag to disable conversion of Hash keys to snake_case. attr_accessor :disable_hash_keys_to_snake_case # Flag to disable conversion of Hash keys to Symbols. attr_accessor :disable_hash_keys_to_symbol # Array of XML nodes to add a namespace to. attr_accessor :nodes_to_namespace # The namespace for nodes set by nodes_to_namespace. attr_accessor :node_namespace # Shortcut method for translating between XML Strings and Ruby Hashes. # Delegates to xml_to_hash in case +source+ is of type String or delegates # to hash_to_xml in case +source+ is of type Hash. Returns nil otherwise. def [](source, root_node = nil) case source when String xml_to_hash(source, root_node) when Hash hash_to_xml(source) else nil end end # Yields this class object in case a +block+ was given. Nice way for setting # multiple options at once. def setup yield self if block_given? end # Converts a given +xml+ String into a Ruby Hash. Starts parsing at root # node by default. The optional +root_node+ parameter can be used to specify # a custom root node to start parsing at using an XPath (Hpricot search). # The root node itself won't be included in the Hash. Converts tag names # from CamelCase/lowerCamelCase to snake_case and into Symbols by default. # # ==== Examples # # xml = "Gorilla" # ApricotEatsGorilla[xml] # # => { :eats => "Gorilla" } # # xml = "Gorillas" # ApricotEatsGorilla[xml, "//eats"] # # => { :lots_of => "Gorillas" } def xml_to_hash(xml, root_node = nil) doc = Hpricot.XML remove_whitespace(xml) root = root_node ? doc.search(root_node) : doc.root return nil if root.nil? return xml_node_to_hash(root) unless root.respond_to? :each if root.size == 1 return root.first.children.to_s if root.first.children.first.kind_of?(Hpricot::Text) xml_node_to_hash(root.first) else root.map do |node| if node.children.first.kind_of?(Hpricot::Text) node.children.to_s else xml_node_to_hash(node) end end end end # Converts a given Ruby Hash into an XML String. Converts Hash keys from # snake_case to lowerCamelCase by default. # # ==== Examples # # hash = { :apricot => { :eats => "Gorilla" } } # ApricotEatsGorilla[hash] # # => "Gorilla" # # hash = { :apricot => { :eats => [ "Gorillas", "Snakes" ] } } # ApricotEatsGorilla[hash] # # => "GorillasSnakes" def hash_to_xml(hash) nested_data_to_xml(hash.keys.first, hash.values.first) end # Builds a SOAP request envelope and includes the content from a given +block+ # into the envelope body. Accepts a Hash (namespace => namespace_uri) of # additional +namespaces+ to set. # # ==== Examples # # ApricotEatsGorilla.soap_envelope { "me" } # # # => ' # # => # # => me # # => # # => ' # # ApricotEatsGorilla.soap_envelope :wsdl => "http://example.com" do # "me" # end # # # => ' xmlns:wsdl="http://example.com" # # => xmlns:env="http://schemas.xmlsoap.org/soap/envelope/"> # # => # # => me # # => # # => ' def soap_envelope(namespaces = {}) namespaces["env"] = "http://schemas.xmlsoap.org/soap/envelope/" tag("env:Envelope", namespaces) do tag("env:Body") do yield if block_given? end end end # Converts a given +string+ from CamelCase/lowerCamelCase to snake_case. def to_snake_case(string) string = string.gsub(/[A-Z]+/, '\1_\0').downcase string = string[1, string.length-1] if string[0, 1] == "_" string end # Converts a given +string+ from snake_case to lowerCamelCase. def to_lower_camel_case(string) string.to_s.gsub(/_(.)/) { $1.upcase } end private # Actual implementation for xml_to_hash. Takes and iterates through a given # Hpricot +element+ and returns a Ruby Hash equal to the given content. def xml_node_to_hash(element) this_node = {} element.each_child do |child| # hpricot 0.6.1 returns an empty array, while 0.8 returns nil if child.children.nil? || child.children.empty? key, value = child.name, nil elsif child.children.size == 1 && child.children.first.text? key, value = child.name, map_to_boolean(child.children.first.to_html) else key, value = child.name, xml_node_to_hash(child) end key = remove_namespace(key) key = to_snake_case(key) unless disable_hash_keys_to_snake_case key = key.intern unless disable_hash_keys_to_symbol current = this_node[key] case current when Array this_node[key] << value when nil this_node[key] = value else this_node[key] = [current.dup, value] end end this_node end # Actual implementation for hash_to_xml. Takes a Hash key +name+ and a # value +item+ and returns an XML String equal to the given content. def nested_data_to_xml(name, item) case item when Array item.map { |subitem| nested_data_to_xml(name, subitem) }.join when Hash tag(name) do opt_order(item).map { |tag, value| case value when Array value.map { |subitem| nested_data_to_xml(tag, subitem) }.join when Hash nested_data_to_xml(tag, value) else tag(tag) { value.to_s } if value.respond_to? :to_s end }.join end else tag(name) { item.to_s } if item.respond_to? :to_s end end # Creates an XML tag. Expects a block for tag content. Defaults to an empty # element tag in case no block was supplied. def tag(name, attributes = {}) name = to_lower_camel_case(name) unless disable_tag_names_to_lower_camel_case if nodes_to_namespace.kind_of? Array name = "#{node_namespace}:#{name}" if node_namespace && nodes_to_namespace.include?(name) end return "<#{name} />" unless block_given? attr = opt_order(attributes).map { |k, v| %Q( xmlns:#{k}="#{v}") }.to_s body = yield || "" "<#{name}#{attr}>" << body << "" end # Removes whitespace between tags of a given +xml+ String. def remove_whitespace(xml) xml.gsub(/(>)\s*(<)/, '\1\2') end # Removes the namespace from a given XML +tag+. def remove_namespace(tag) tag.sub(/.+:(.+)/, '\1') end # Checks to see if a given +string+ matches "true" or "false" and converts # these values to actual Boolean objects. Returns the original String in # case it does not match "true" or "false". def map_to_boolean(string) return true if string == "true" return false if string == "false" Iconv.new("iso-8859-1", "utf-8").iconv(string) end # Returns a sorted version of a given +hash+ if sort_keys is enabled. def opt_order(hash) return hash unless sort_keys hash.keys.sort_by { |s| s.to_s }.map { |key| [key, hash[key]] } end end end