# frozen_string_literal: true
require 'nokogiri'
require 'date'
require 'time'
require 'happymapper/version'
require 'happymapper/anonymous_mapper'
module HappyMapper
class Boolean; end
class XmlContent; end
def self.parse(xml_content)
AnonymousMapper.new.parse(xml_content)
end
def self.included(base)
if base.superclass <= HappyMapper
base.instance_eval do
@attributes =
superclass.instance_variable_get(:@attributes).dup
@elements =
superclass.instance_variable_get(:@elements).dup
@registered_namespaces =
superclass.instance_variable_get(:@registered_namespaces).dup
@wrapper_anonymous_classes =
superclass.instance_variable_get(:@wrapper_anonymous_classes).dup
end
else
base.instance_eval do
@attributes = {}
@elements = {}
@registered_namespaces = {}
@wrapper_anonymous_classes = {}
end
end
base.extend ClassMethods
end
module ClassMethods
#
# The xml has the following attributes defined.
#
# @example
#
# "Germany"
#
# # definition of the 'code' attribute within the class
# attribute :code, String
#
# @param [Symbol] name the name of the accessor that is created
# @param [String,Class] type the class name of the name of the class whcih
# the object will be converted upon parsing
# @param [Hash] options additional parameters to send to the relationship
#
def attribute(name, type, options = {})
attribute = Attribute.new(name, type, options)
@attributes[name] = attribute
attr_accessor attribute.method_name.intern
end
#
# The elements defined through {#attribute}.
#
# @return [Array] a list of the attributes defined for this class;
# an empty array is returned when there have been no attributes defined.
#
def attributes
@attributes.values
end
#
# Register a namespace that is used to persist the object namespace back to
# XML.
#
# @example
#
# register_namespace 'prefix', 'http://www.unicornland.com/prefix'
#
# # the output will contain the namespace defined
#
# "
# ...
# "
#
# @param [String] name the xml prefix
# @param [String] href url for the xml namespace
#
def register_namespace(name, href)
@registered_namespaces.merge!(name => href)
end
#
# An element defined in the XML that is parsed.
#
# @example
#
# "
# Oldenburg
# "
#
# # definition of the 'city' element within the class
#
# element :city, String
#
# @param [Symbol] name the name of the accessor that is created
# @param [String,Class] type the class name of the name of the class whcih
# the object will be converted upon parsing
# @param [Hash] options additional parameters to send to the relationship
#
def element(name, type, options = {})
element = Element.new(name, type, options)
attr_accessor element.method_name.intern unless @elements[name]
@elements[name] = element
end
#
# The elements defined through {#element}, {#has_one}, and {#has_many}.
#
# @return [Array] a list of the elements contained defined for this
# class; an empty array is returned when there have been no elements
# defined.
#
def elements
@elements.values
end
#
# The value stored in the text node of the current element.
#
# @example
#
# "Michael Jackson"
#
# # definition of the 'firstName' text node within the class
#
# content :first_name, String
#
# @param [Symbol] name the name of the accessor that is created
# @param [String,Class] type the class name of the name of the class whcih
# the object will be converted upon parsing. By Default String class will be taken.
# @param [Hash] options additional parameters to send to the relationship
#
def content(name, type = String, options = {})
@content = TextNode.new(name, type, options)
attr_accessor @content.method_name.intern
end
#
# Sets the object to have xml content, this will assign the XML contents
# that are parsed to the attribute accessor xml_content. The object will
# respond to the method #xml_content and will return the XML data that
# it has parsed.
#
def has_xml_content
attr_accessor :xml_content
end
#
# The object has one of these elements in the XML. If there are multiple,
# the last one will be set to this value.
#
# @param [Symbol] name the name of the accessor that is created
# @param [String,Class] type the class name of the name of the class whcih
# the object will be converted upon parsing
# @param [Hash] options additional parameters to send to the relationship
#
# @see #element
#
def has_one(name, type, options = {})
element name, type, { single: true }.merge(options)
end
#
# The object has many of these elements in the XML.
#
# @param [Symbol] name the name of accessor that is created
# @param [String,Class] type the class name or the name of the class which
# the object will be converted upon parsing.
# @param [Hash] options additional parameters to send to the relationship
#
# @see #element
#
def has_many(name, type, options = {})
element name, type, { single: false }.merge(options)
end
#
# The list of registered after_parse callbacks.
#
def after_parse_callbacks
@after_parse_callbacks ||= []
end
#
# Register a new after_parse callback, given as a block.
#
# @yield [object] Yields the newly-parsed object to the block after parsing.
# Sub-objects will be already populated.
def after_parse(&block)
after_parse_callbacks.push(block)
end
#
# Specify a namespace if a node and all its children are all namespaced
# elements. This is simpler than passing the :namespace option to each
# defined element.
#
# @param [String] namespace the namespace to set as default for the class
# element.
#
def namespace(namespace = nil)
@namespace = namespace if namespace
@namespace if defined? @namespace
end
#
# @param [String] new_tag_name the name for the tag
#
def tag(new_tag_name)
@tag_name = new_tag_name.to_s unless new_tag_name.nil? || new_tag_name.to_s.empty?
end
#
# The name of the tag
#
# @return [String] the name of the tag as a string, downcased
#
def tag_name
@tag_name ||= name && name.to_s.split('::')[-1].downcase
end
# There is an XML tag that needs to be known for parsing and should be generated
# during a to_xml. But it doesn't need to be a class and the contained elements should
# be made available on the parent class
#
# @param [String] name the name of the element that is just a place holder
# @param [Proc] blk the element definitions inside the place holder tag
#
def wrap(name, &blk)
# Get an anonymous HappyMapper that has 'name' as its tag and defined
# in '&blk'. Then save that to a class instance variable for later use
wrapper = AnonymousWrapperClassFactory.get(name, &blk)
wrapper_key = wrapper.inspect
@wrapper_anonymous_classes[wrapper_key] = wrapper
# Create getter/setter for each element and attribute defined on the anonymous HappyMapper
# onto this class. They get/set the value by passing thru to the anonymous class.
passthrus = wrapper.attributes + wrapper.elements
passthrus.each do |item|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{item.method_name}
@#{name} ||= self.class.instance_variable_get('@wrapper_anonymous_classes')['#{wrapper_key}'].new
@#{name}.#{item.method_name}
end
def #{item.method_name}=(value)
@#{name} ||= self.class.instance_variable_get('@wrapper_anonymous_classes')['#{wrapper_key}'].new
@#{name}.#{item.method_name} = value
end
RUBY
end
has_one name, wrapper
end
# The callback defined through {.with_nokogiri_config}.
#
# @return [Proc] the proc to pass to Nokogiri to setup parse options. nil if empty.
#
attr_reader :nokogiri_config_callback
# Register a config callback according to the block Nokogori expects when
# calling Nokogiri::XML::Document.parse().
#
# See http://nokogiri.org/Nokogiri/XML/Document.html#method-c-parse
#
# @param [Proc] the proc to pass to Nokogiri to setup parse options
#
def with_nokogiri_config(&blk)
@nokogiri_config_callback = blk
end
#
# @param [Nokogiri::XML::Node,Nokogiri:XML::Document,String] xml the XML
# contents to convert into Object.
# @param [Hash] options additional information for parsing.
# :single => true if requesting a single object, otherwise it defaults
# to retuning an array of multiple items.
# :xpath information where to start the parsing
# :namespace is the namespace to use for additional information.
#
def parse(xml, options = {})
# Capture any provided namespaces and merge in any namespaces that have
# been registered on the object.
namespaces = options[:namespaces] || {}
namespaces = namespaces.merge(@registered_namespaces)
# If the XML specified is an Node then we have what we need.
if xml.is_a?(Nokogiri::XML::Node) && !xml.is_a?(Nokogiri::XML::Document)
node = xml
else
unless xml.is_a?(Nokogiri::XML::Document)
# Attempt to parse the xml value with Nokogiri XML as a document
# and select the root element
xml = Nokogiri::XML(
xml, nil, nil,
Nokogiri::XML::ParseOptions::STRICT,
&nokogiri_config_callback
)
end
# Now xml is certainly an XML document: Select the root node of the document
node = xml.root
# merge any namespaces found on the xml node into the namespace hash
namespaces = namespaces.merge(xml.collect_namespaces)
# if the node name is equal to the tag name then the we are parsing the
# root element and that is important to record so that we can apply
# the correct xpath on the elements of this document.
root = node.name == tag_name
end
# If the :single option has been specified or we are at the root element
# then we are going to return a single element or nil if no nodes are found
single = root || options[:single]
# if a namespace has been provided then set the current namespace to it
# or use the namespace provided by the class
# or use the 'xmlns' namespace if defined
namespace = options[:namespace] || self.namespace || namespaces.key?('xmlns') && 'xmlns'
# from the options grab any nodes present and if none are present then
# perform the following to find the nodes for the given class
nodes = options.fetch(:nodes) do
find_nodes_to_parse(options, namespace, tag_name, namespaces, node, root)
end
# Nothing matching found, we can go ahead and return
return (single ? nil : []) if nodes.empty?
# If the :limit option has been specified then we are going to slice
# our node results by that amount to allow us the ability to deal with
# a large result set of data.
limit = options[:in_groups_of] || nodes.size
# If the limit of 0 has been specified then the user obviously wants
# none of the nodes that we are serving within this batch of nodes.
return [] if limit == 0
collection = []
nodes.each_slice(limit) do |slice|
part = slice.map do |n|
parse_node(n, options, namespace, namespaces)
end
# If a block has been provided and the user has requested that the objects
# be handled in groups then we should yield the slice of the objects to them
# otherwise continue to lump them together
if block_given? && options[:in_groups_of]
yield part
else
collection += part
end
end
# If we're parsing a single element then we are going to return the first
# item in the collection. Otherwise the return response is going to be an
# entire array of items.
if single
collection.first
else
collection
end
end
# @private
def defined_content
@content if defined? @content
end
private
def find_nodes_to_parse(options, namespace, tag_name, namespaces, node, root)
# when at the root use the xpath '/' otherwise use a more gready './/'
# unless an xpath has been specified, which should overwrite default
# and finally attach the current namespace if one has been defined
#
xpath = if options[:xpath]
options[:xpath].to_s.sub(%r{([^/])$}, '\1/')
elsif root
'/'
else
'.//'
end
if namespace
return [] unless namespaces.find { |name, _url| ["xmlns:#{namespace}", namespace].include? name }
xpath += "#{namespace}:"
end
nodes = []
# when finding nodes, do it in this order:
# 1. specified tag if one has been provided
# 2. name of element
# 3. tag_name (derived from class name by default)
# If a tag has been provided we need to search for it.
if options.key?(:tag)
nodes = node.xpath(xpath + options[:tag].to_s, namespaces)
else
# This is the default case when no tag value is provided.
# First we use the name of the element `items` in `has_many items`
# Second we use the tag name which is the name of the class cleaned up
[options[:name], tag_name].compact.each do |xpath_ext|
nodes = node.xpath(xpath + xpath_ext.to_s, namespaces)
break if nodes && !nodes.empty?
end
end
nodes
end
def parse_node(node, options, namespace, namespaces)
# If an existing HappyMapper object is provided, update it with the
# values from the xml being parsed. Otherwise, create a new object
obj = options[:update] || new
attributes.each do |attr|
value = attr.from_xml_node(node, namespace, namespaces)
value = attr.default if value.nil?
obj.send("#{attr.method_name}=", value)
end
elements.each do |elem|
obj.send("#{elem.method_name}=", elem.from_xml_node(node, namespace, namespaces))
end
if (content = defined_content)
obj.send("#{content.method_name}=", content.from_xml_node(node, namespace, namespaces))
end
# If the HappyMapper class has the method #xml_value=,
# attr_writer :xml_value, or attr_accessor :xml_value then we want to
# assign the current xml that we just parsed to the xml_value
if obj.respond_to?('xml_value=')
obj.xml_value = node.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
end
# If the HappyMapper class has the method #xml_content=,
# attr_write :xml_content, or attr_accessor :xml_content then we want to
# assign the child xml that we just parsed to the xml_content
if obj.respond_to?('xml_content=')
node = node.children if node.respond_to?(:children)
obj.xml_content = node.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
end
# Call any registered after_parse callbacks for the object's class
obj.class.after_parse_callbacks.each { |callback| callback.call(obj) }
# collect the object that we have created
obj
end
end
# Set all attributes with a default to their default values
def initialize
super
self.class.attributes.reject { |attr| attr.default.nil? }.each do |attr|
send("#{attr.method_name}=", attr.default)
end
end
#
# Create an xml representation of the specified class based on defined
# HappyMapper elements and attributes. The method is defined in a way
# that it can be called recursively by classes that are also HappyMapper
# classes, allowg for the composition of classes.
#
# @param [Nokogiri::XML::Builder] builder an instance of the XML builder which
# is being used when called recursively.
# @param [String] default_namespace The name of the namespace which is the
# default for the xml being produced; this is the namespace of the
# parent
# @param [String] namespace_override The namespace specified with the element
# declaration in the parent. Overrides the namespace declaration in the
# element class itself when calling #to_xml recursively.
# @param [String] tag_from_parent The xml tag to use on the element when being
# called recursively. This lets the parent doc define its own structure.
# Otherwise the element uses the tag it has defined for itself. Should only
# apply when calling a child HappyMapper element.
#
# @return [String,Nokogiri::XML::Builder] return XML representation of the
# HappyMapper object; when called recursively this is going to return
# and Nokogiri::XML::Builder object.
#
def to_xml(builder = nil, default_namespace = nil, namespace_override = nil,
tag_from_parent = nil)
#
# If to_xml has been called without a passed in builder instance that
# means we are going to return xml output. When it has been called with
# a builder instance that means we most likely being called recursively
# and will return the end product as a builder instance.
#
unless builder
write_out_to_xml = true
builder = Nokogiri::XML::Builder.new
end
attributes = collect_writable_attributes
#
# If the object we are serializing has a namespace declaration we will want
# to use that namespace or we will use the default namespace.
# When neither are specifed we are simply using whatever is default to the
# builder
#
namespace_name = namespace_override || self.class.namespace || default_namespace
#
# Create a tag in the builder that matches the class's tag name unless a tag was passed
# in a recursive call from the parent doc. Then append
# any attributes to the element that were defined above.
#
builder.send("#{tag_from_parent || self.class.tag_name}_", attributes) do |xml|
register_namespaces_with_builder(builder)
xml.parent.namespace =
builder.doc.root.namespace_definitions.find { |x| x.prefix == namespace_name }
#
# When a content has been defined we add the resulting value
# the output xml
#
if (content = self.class.defined_content)
unless content.options[:read_only]
value = send(content.name)
value = apply_on_save_action(content, value)
builder.text(value)
end
end
#
# for every define element (i.e. has_one, has_many, element) we are
# going to persist each one
#
self.class.elements.each do |element|
element_to_xml(element, xml, default_namespace)
end
end
# Write out to XML, this value was set above, based on whether or not an XML
# builder object was passed to it as a parameter. When there was no parameter
# we assume we are at the root level of the #to_xml call and want the actual
# xml generated from the object. If an XML builder instance was specified
# then we assume that has been called recursively to generate a larger
# XML document.
write_out_to_xml ? builder.to_xml.force_encoding('UTF-8') : builder
end
# Parse the xml and update this instance. This does not update instances
# of HappyMappers that are children of this object. New instances will be
# created for any HappyMapper children of this object.
#
# Params and return are the same as the class parse() method above.
def parse(xml, options = {})
self.class.parse(xml, options.merge!(update: self))
end
# Factory for creating anonmyous HappyMappers
class AnonymousWrapperClassFactory
def self.get(name, &blk)
Class.new do
include HappyMapper
tag name
instance_eval(&blk)
end
end
end
private
#
# If the item defines an on_save lambda/proc or value that maps to a method
# that the class has defined, then call it with the value as a parameter.
# This allows for operations to be performed to convert the value to a
# specific value to be saved to the xml.
#
def apply_on_save_action(item, value)
if (on_save_action = item.options[:on_save])
if on_save_action.is_a?(Proc)
value = on_save_action.call(value)
elsif respond_to?(on_save_action)
value = send(on_save_action, value)
end
end
value
end
#
# Find the attributes for the class and collect them into a Hash structure
#
def collect_writable_attributes
#
# Find the attributes for the class and collect them into an array
# that will be placed into a Hash structure
#
attributes = self.class.attributes.collect do |attribute|
#
# If an attribute is marked as read_only then we want to ignore the attribute
# when it comes to saving the xml document; so we will not go into any of
# the below process
#
if attribute.options[:read_only]
[]
else
value = send(attribute.method_name)
value = nil if value == attribute.default
#
# Apply any on_save lambda/proc or value defined on the attribute.
#
value = apply_on_save_action(attribute, value)
#
# Attributes that have a nil value should be ignored unless they explicitly
# state that they should be expressed in the output.
#
if !(value.nil? || attribute.options[:state_when_nil])
attribute_namespace = attribute.options[:namespace]
["#{attribute_namespace ? "#{attribute_namespace}:" : ''}#{attribute.tag}", value]
else
[]
end
end
end.flatten
Hash[*attributes]
end
#
# Add all the registered namespaces to the builder's root element.
# When this is called recursively by composed classes the namespaces
# are still added to the root element
#
# However, we do not want to add the namespace if the namespace is 'xmlns'
# which means that it is the default namespace of the code.
#
def register_namespaces_with_builder(builder)
return unless self.class.instance_variable_get('@registered_namespaces')
self.class.instance_variable_get('@registered_namespaces').sort.each do |name, href|
name = nil if name == 'xmlns'
builder.doc.root.add_namespace(name, href)
end
end
# Persist a single nested element as xml
def element_to_xml(element, xml, default_namespace)
#
# If an element is marked as read only do not consider at all when
# saving to XML.
#
return if element.options[:read_only]
tag = element.tag || element.name
#
# The value to store is the result of the method call to the element,
# by default this is simply utilizing the attr_accessor defined. However,
# this allows for this method to be overridden
#
value = send(element.name)
#
# Apply any on_save action defined on the element.
#
value = apply_on_save_action(element, value)
#
# To allow for us to treat both groups of items and singular items
# equally we wrap the value and treat it as an array.
#
values = if value.respond_to?(:to_ary) && !element.options[:single]
value.to_ary
else
[value]
end
values.each do |item|
if item.is_a?(HappyMapper)
#
# Other items are convertable to xml through the xml builder
# process should have their contents retrieved and attached
# to the builder structure
#
item.to_xml(xml, self.class.namespace || default_namespace,
element.options[:namespace],
element.options[:tag] || nil)
elsif !item.nil?
item_namespace = element.options[:namespace] || self.class.namespace || default_namespace
#
# When a value exists we should append the value for the tag
#
if item_namespace
xml[item_namespace].send("#{tag}_", item.to_s)
else
xml.send("#{tag}_", item.to_s)
end
elsif element.options[:state_when_nil]
#
# Normally a nil value would be ignored, however if specified then
# an empty element will be written to the xml
#
xml.send("#{tag}_", '')
end
end
end
end
require 'happymapper/supported_types'
require 'happymapper/item'
require 'happymapper/attribute'
require 'happymapper/element'
require 'happymapper/text_node'