# = XmlConfigFile # # Author:: Maik Schmidt (mailto:contact@maik-schmidt.de) # Copyright:: Copyright (c) 2003,2004 Maik Schmidt # License:: Distributes under the same terms as Ruby require 'observer' require 'thread' require 'rexml/document' require 'rexml/xpath' # This class represents an XML configuration file as it is # used by many modern applications. Using this class you # can access an XML file's content easily using XPath. class XmlConfigFile include Observable include REXML DEFAULT_SEPARATOR = "." DEFAULT_TRUE_VALUES = ['1', 'yes', 'true', 'on'] DEFAULT_FALSE_VALUES = ['0', 'no', 'false', 'off'] attr_reader :filename, :true_values, :false_values, :doc attr_accessor :expand_attributes, :path_prefix attr_accessor :no_hash_key_element_path # Conversion of DEFAULT_TRUE_VALUES to Ruby Boolean # objects, everything else to strings. attr_accessor :auto_convert_boolean # Conversion of any node text to Ruby Object attempted # by evaluation, otherwise returns string value of node. attr_accessor :auto_convert_any # Creates and initializes a new XmlConfigFile object. # # filename:: # Name of the configuration file to be loaded. # reloadPeriod:: # If the configuration file should be reloaded periodically, # set this parameter to a numeric value greater than zero, # which specifies the reload period measured in seconds. # # The following exceptions may be raised: # # Errno::ENOENT:: # If the specified file does not exist. # REXML::ParseException:: # If the specified file is not wellformed. # ArgumentError:: # If the reload period is not a number greater than zero. def initialize(filename, reloadPeriod = nil) @filename = filename @fileaccess = Mutex.new @true_values = DEFAULT_TRUE_VALUES @false_values = DEFAULT_FALSE_VALUES @expand_attributes = false @path_prefix = '' @doc = load_configuration_file(@filename) @reloaderThread = initialize_reloader_thread(reloadPeriod) end # Returns a configuration parameter as an Object. The # parameter itself has to be identified by an XPath # expression. # # In the event that an Object isn't created it will # return a string # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as String value. # default:: # Default value to be returned, if the XPath expression # returned an empty node list. Of course, the default # value should be a String value in such a case. The # method does not check this explicitly. def get_object(xpath, default = nil) parameter = XPath.first(@doc, @path_prefix + xpath) return node_to_object(parameter, default) end alias_method :getObject, :get_object alias_method :getParameterObject, :get_object # Returns a configuration parameter as a String. The # parameter itself has to be identified by an XPath # expression. # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as String value. # default:: # Default value to be returned, if the XPath expression # returned an empty node list. Of course, the default # value should be a String value in such a case. The # method does not check this explicitly. def get_string(xpath, default = nil) parameter = XPath.first(@doc, @path_prefix + xpath) return node_to_text(parameter, default) end alias_method :[], :get_string alias_method :getParameter, :get_string alias_method :get_parameter, :get_string # Sets a configuration parameter. The parameter has to # be specified by an XPath expression. # If the parameter was set successfully, all observers # will be notified. # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as String value. # value:: # The parameter value to be set. # create:: # If the node specified by xpath does not exist, it # will be created, if this parameter is set to true. # Otherwise, an exception will be thrown. def set_parameter(xpath, value, create = false) parameter = XPath.first(@doc, xpath) if parameter.nil? if create # # Re-Call the set method if the create succeeds # return set_parameter(xpath,value) if create_path(xpath) # raise ArgumentError, "Not yet implemented!" end raise ArgumentError , "XPath Doesn't Exist :: Create is #{create}" elsif parameter.instance_of?(Element) unless value.nil? parameter.text = value.to_s else parameter.text = nil end elsif parameter.instance_of?(Attribute) parameter.element.add_attribute(parameter.name, value.to_s) end changed notify_observers(@filename) end alias_method :[]=, :set_parameter # # Add a value according to an xpath. Parents will be created if they don't # exist. This is set_parameter with the create flag set to true # def add_parameter(xpath,value) set_parameter(xpath,value,true) end # Returns a configuration parameter as an Integer. The # parameter itself has to be identified by an XPath # expression. # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as Integer value. # default:: # Default value to be returned, if the XPath expression # returned an empty node list. Of course, the default # value should be an Integer value in such a case. The # method does not check this explicitly. # # The following exception may be raised: # # ArgumentError:: # If a parameter was found and could not be converted # into an Integer. def get_int(xpath, default = nil) parameter = get_string(xpath) parameter ? Integer(parameter) : default end alias_method :getIntParameter, :get_int alias_method :get_int_parameter, :get_int # Returns a configuration parameter as a Float. The # parameter itself has to be identified by an XPath # expression. # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as Float value. # default:: # Default value to be returned, if the XPath expression # returned an empty node list. Of course, the default # value should be a Float value in such a case. The # method does not check this explicitly. # # The following exception may be raised: # # ArgumentError:: # If a parameter was found and could not be converted # into a Float. def get_float(xpath, default = nil) parameter = get_string(xpath) parameter ? Float(parameter) : default end alias_method :getFloatParameter, :get_float alias_method :get_float_parameter, :get_float # Returns a configuration parameter as a boolean. The # parameter itself has to be identified by an XPath # expression. # # By default, the values in DEFAULT_TRUE_VALUES are # assumed to be true and the values in DEFAULT_FALSE_VALUES # are assumed to be false. You can define your own # values by setting true_values respectively false_values # to an array containing the values of your choice. # # xpath:: # An XPath expression identifying the configuration # parameter to be read and returned as boolean value. # default:: # Default value to be returned, if the XPath expression # returned an empty node list. Of course, the default # value should be a boolean value in such a case. The # method does not check this explicitly. # # The following exception may be raised: # # ArgumentError:: # If a parameter was found and could not be converted # into a boolean. def get_boolean(xpath, default = nil) parameter = get_string(xpath) return default unless parameter value = convert_boolean(parameter) return value unless value.nil? raise ArgumentError end alias_method :getBooleanParameter, :get_boolean alias_method :get_boolean_parameter, :get_boolean # Returns a set of String parameters as a one-dimensional # Hash. # If no parameters specified by xpath were found, an # empty Hash will be returned. # # E.g. the document snippet # # ... # # shop # scott # # ... # # will become the following Hash # # { db.name => shop, db.user => scott } # # xpath:: # An XPath expression specifying the nodes to be selected. # pathSeparator:: # String separating the single path elements in the Hash keys. def get_parameters(xpath, pathSeparator = DEFAULT_SEPARATOR) parameters = Hash.new XPath.each(@doc, xpath) { |node| parameters.update(get_properties(node, pathSeparator)) } return parameters end alias_method :getParameters, :get_parameters # Returns an array of Hashes. Each element contains a set of String # parameters as Hash. # If no parameters specified by xpath were found, an empty Array # will be returned. # # E.g. the document snippet # # ... # # shop # scott # # # factory # anna # # ... # # will become the following Array # # [{ db.name => shop, db.user => scott }, # { db.name => factory, db.user => anna }] # # This method was originally contributed by Nigel Ball. # # xpath:: # An XPath expression specifying the nodes to be selected. # pathSeparator:: # String separating the single path elements in the Hash keys. def get_string_array(xpath, pathSeparator = DEFAULT_SEPARATOR, expand = false) old_expand_attributes = @expand_attributes @expand_attributes = expand parameter_array = Array.new XPath.each(@doc, xpath) { |node| parameter_array << get_properties(node, pathSeparator) } @expand_attributes = old_expand_attributes return parameter_array end alias_method :get_parameter_array, :get_string_array # Sets the list of values, that will be interpreted as true # boolean parameters. The case of all the elements will be # set to downcase and all leading and trailing whitespaces # will be removed. # # values:: # List of values to be interpreted as true boolean values. def true_values=(values) @true_values = values.collect { |x| x.strip.downcase } end # Sets the list of values, that will be interpreted as false # boolean parameters. The case of all the elements will be # set to downcase and all leading and trailing whitespaces # will be removed. # # values:: # List of values to be interpreted as false boolean values. def false_values=(values) @false_values = values.collect { |x| x.strip.downcase } end # Stores the current configuration into a file. # # filename:: # Name of the file to store configuration in. Defaults # to the current configuration file's name. def store(filename = nil) filename = @filename if filename.nil? @fileaccess.synchronize { File.open(filename, "w") { |f| f.write(to_s) } } end # Returns the current configuration as an XML string. def to_s content = '' @doc.write(content, 0) return content #return @doc.to_s end # Closes the configuration file, i.e. stops the reloader # thread. def close @reloaderThread.exit unless @reloaderThread.nil? @reloaderThread = nil end # Converts the current configuration into a Hash in the same # way as the Perl module XML::Simple. def to_hash end private # Initializes and returns a reloader thread, that will reload # the configuration file periodically (only if it has changed, # of course). If the configuration was reloaded, all observers # will be notified. # If the configuration file to be reloaded is not # wellformed or could not be found, an error message # will be printed to standard error and the last # working configuration will be used. # # reloadPeriod:: # Reload period measured in seconds. # # The following exception may be raised: # # ArgumentError:: # If the reload period is not a number greater than zero. def initialize_reloader_thread(reloadPeriod) return nil if reloadPeriod.nil? if !reloadPeriod.kind_of?(Integer) && !reloadPeriod.kind_of?(Float) raise ArgumentError elsif reloadPeriod <= 0 raise ArgumentError end @lastModificationTime = File::mtime(@filename).to_i return Thread.new { loop do sleep(reloadPeriod) begin currentModificationTime = File::mtime(@filename).to_i if currentModificationTime != @lastModificationTime @lastModificationTime = currentModificationTime @fileaccess.synchronize { currentConfiguration = load_configuration_file(@filename) @doc = currentConfiguration } changed notify_observers(@filename) end rescue Exception => ex $stderr.puts("Invalid configuration in '#{@filename}':", ex) end end } end # Loads and parses an XML configuration file. Additionally, # references to environment variables will be replaced by # their actual values. # # filename:: # Name of the configuration file to be loaded. # substituteVariables:: # Indicates, if references to environment variables should # be replaced by their values. # # The following exceptions may be raised: # # Errno::ENOENT:: # If the specified file does not exist. # REXML::ParseException:: # If the specified file is not wellformed. def load_configuration_file(filename, substituteVariables = true) xmlstring = File.readlines(filename).to_s doc = Document.new(xmlstring) substitute_environment_variables(doc) if substituteVariables return doc end # Replaces all references to environment variables in a # String by their actual values. # # text:: # A String containing references to environment # variables to be replaced. def substitute_environment_variable(text) text.gsub!(/\$\{(\w+)\}/) { |s| ENV[$1] ? ENV[$1] : "" } text end # Replaces all references to environment variables in a # document with their actual values. # # doc:: # Document containing references to be replaced. def substitute_environment_variables(doc) XPath.each(doc, '//*') { |element| if element.instance_of?(Element) if element.text element.text = substitute_environment_variable(element.text) end element.attributes.each { |name, value| substitute_environment_variable(value) } end } end # Converts a document node into a String. # If the node could not be converted into a String # for any reason, default will be returned. # # node:: # Document node to be converted. # default:: # Value to be returned, if node could not be converted. def node_to_text(node, default = nil) if node.instance_of?(Element) # Bug fix provided by Sandra Silcot. return node.text.nil? ? default : node.text.strip elsif node.instance_of?(Attribute) return node.value.nil? ? default : node.value.strip elsif node.instance_of?(Text) return node.to_s.strip else return default end end # Converts a document node into a Ruby object. # # If :auto_convert_boolean is set then String # objects matching DEFAULT_FALSE_VALUES or DEFAULT_TRUE_VALUES # will be converted to a Ruby Boolean Object. # # If :auto_convert_any is set then String objects # will be attempted to be converted to evaluated values. # DEFAULT_[TRUE|FALSE]_VALUES will be ignored. # # "1" becomes FixNum instance # "true" becomes TrueClass instance # "blah" becomes String instance def conditional_node_to_object(node, default = nil) if self.auto_convert_any return node_to_object(node,default) elsif self.auto_convert_boolean return node_to_boolean(node,default) else return node_to_text(node,default) end end # Converts a document node into a Boolean. # # If matches against DEFAULT_TRUE_VALUES 'true' is returned. # If matches against DEFAULT_FALSE_VALUE 'false is returned. # # If the Node cannot be converted to Boolean, then it # will return the node as it would have been if it was # converted to a String def node_to_boolean(node, default = nil) nodeText = node_to_text(node,default) value = convert_boolean(nodeText) return value unless value.nil? return nodeText end # Converts a document node into a a native ruby object. # # If auto_convert_boolean is on, this # method will defer to the DEFAULT_TRUE_VALLUES # and DEFAULT_FALSE_VALUES for conversion. # # This will cause behaviour like '1' and '0' to become # 'true', 'false' instead of FixNum instance 1 and 0. # # Attempts to evaluate the nodeText, if it cannot it will # just return the node's text. def node_to_object(node, default = nil) nodeText = node_to_text(node,default) retried = false if self.auto_convert_boolean value = convert_boolean(nodeText) return value unless value.nil? end if not nodeText.nil? evalString = nodeText else evalString = '' end begin val = eval(evalString) rescue SyntaxError, NameError, TypeError # Would like to find out what all the possible # errors of eval would be. # # Curtis Says: # # This is probably an ugly hack. # # Uncertain if trying lowercase will # have an unwanted effect. This is # for 'TrUe' to evaluate to true::TrueClass if not retried retried = true evalString = nodeText.downcase retry end val = nodeText end val end # Converts a string into Boolean object, # # Returns nil on un-success. def convert_boolean(parameter) unless parameter.nil? lowParam = parameter.downcase return true if @true_values.include?(lowParam) return false if @false_values.include?(lowParam) end return nil end # Returns the path to a node as String. All elements # will be delimited by the string pathSeparator. # # node:: # Node to return path for. # pathSeparator:: # String separating the single path elements in # the Hash keys. def get_path(node, pathSeparator) unless self.no_hash_key_element_path if node.parent == @doc.root return node.name else return get_path(node.parent, pathSeparator) + pathSeparator + node.name end else return node.name end end # Converts a document node into a Hash object. # # node:: # Node to be converted into a Hash. # pathSeparator:: # String separating the single path elements in the Hash keys. def get_properties(node, pathSeparator) parameters = Hash.new if node.instance_of?(Element) if !node.has_elements? parameters[get_path(node, pathSeparator)] = conditional_node_to_object(node) else node.each_element { |child| parameters.update(get_properties(child, pathSeparator)) } end if @expand_attributes node.attributes.each { |name, value| parameters[get_path(node, pathSeparator) + pathSeparator + name] = value } end end return parameters end # # Creates a specified XPath. # # Currently only supports absolute paths, non-indexed (first match) # # # xpath:: # An XPath expression identifying a complete path of nodes or attributes that # should exist in the xml document after the execution of this method. # # Return true if success # # The following Exceptions may be thrown # # ArgumentError:: # when unsupported features of xpath attempt to be created # def create_path(xpath) # # Iterate frontwards through each portion of the xpath # # current_xpath ="" # # xml/xxpath.rb influenced the idea to use a case with # regular expressions to match against the different part "types" # xpath.split('/').each do | part | unless part.to_s.empty? last_existing_node = XPath.first(@doc,current_xpath) # # This should always set the last_existing path # unless last_existing_node.nil? last_existing_path = current_xpath else raise "AssertFailed :: LastExisiting Path should never be Invalid! -- #{current_xpath}" end current_xpath << '/' << part current_node = XPath.first(@doc,current_xpath) # # Check to see if this part of the path exists # # If it doesn't go through the process to create it # # Otherwise move onto the next portion of the parent if current_node.nil? case part when /^@(\w+)$/ attr_name = $1 create_attribute_at(last_existing_node,attr_name) when /^(\w+)$/ element_name = $1 create_element_at(last_existing_node,element_name) else raise ArgumentError.new("Unsupported XPath Creation Part : #{part} ") end end end end (xpath == current_xpath) end #http://www.ruby-doc.org/stdlib/libdoc/rexml/rdoc/classes/REXML/Element.html # # Create an attribute in the REXML document # def create_attribute_at(node,attribute_name, value = "") node.add_attribute(attribute_name,value) end # # create a node in the XML Document # def create_element_at(node,element_name) node.add_element(element_name) end end