# encoding: utf-8 require 'fedux_org_stdlib/require_files' require 'fedux_org_stdlib/app_config/exceptions' require 'fedux_org_stdlib/process_environment' require 'fedux_org_stdlib/core_ext/array' require 'fedux_org_stdlib/logging/logger' require_library %w{ json psych active_support/core_ext/string/inflections thread set active_support/hash_with_indifferent_access } module FeduxOrgStdlib # This class makes a config file available as an object. The config file # needs to be `YAML` by default. It is read by `Psych` and converted to a # hash. If you chose to use a different file format: Each config file needs # to translatable to a hash or another data structure which responds to # `[]` by the given `config_engine`. If no suitable config file can be found # the config uses only the defined defaults within the class. # # By default it will look for a suitable config file in the given order: # # 1. $HOME/.config//.yaml # 2. $HOME/./.yaml # 2. $HOME/..yaml # 2. $HOME/.rc # 3. /etc/./.yaml # # Please keep in mind # # * application_name: Module of your class, e.g. "MyApplication" becomes # "my_application" # * config_file: Pluarized name of your class and "Config" strip # off, e.g "ClientConfig" becomes "clients.yaml" (mind the pluralized name) # # Most conventions defined by me are implemented as separate methods. If one convention # is not suitable for your use case, just overwrite the method. # # If you prefer to use a different path to the config file or name of the # config file one of the following methods needs to be overwritten: # # * config_file # * config_name # * application_name # # If you want the class to look for your config file at a different place # overwrite the following method # # * allowed_config_file_paths # # Below you find some examples for the usage of the class: # # @example Create config with one writer and reader # module MyApplication # class ClientConfig < AppConfig # # 'data1' is the default for option1 # # if you create a file # option :option1, 'data1' # end # end # # @example Create config with a reader only # module MyApplication # class ClientConfig < AppConfig # # 'data1' is the default for option1 # # if you create a file # option_reader :option1, 'data1' # end # end # # @example Config yaml file for the classes above: clients.yaml # --- # option1: 'data2' class AppConfig class << self # @api private def _options @options ||= Set.new end # Get access to process environment # # This might be handy to define default options # # @example Get variable # # process_environment.fetch('HOME') # # => ENV['HOME'] def process_environment @process_environment ||= ProcessEnvironment.new end # Define a reader for option # # @param [Symbol] option # Name of option # # @param [Object] default_value # The default value of this option def option_reader(option, default_value) option = option.to_sym fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? option define_method option do _config.fetch(option, default_value) end self._options << option end # Define a writer for option # # Please make sure that you define a reader as well. Otherwise you cannot # access the option. Under normal conditions it does not make sense to # use this method. # # @api private # # @param [Symbol] option # Name of option # # @raise [Exceptions::ConfigLocked] # If one tries to modified a locked config def option_writer(option) fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? "#{option}=".to_sym define_method "#{option}=".to_sym do |value| begin _config[option.to_sym] = value rescue RuntimeError raise Exceptions::ConfigLocked end end self._options << option end # Define a writer and a reader for option # # @param [Symbol] option # Name of option # # @param [Object] default_value # Default value of option def option(option, default_value) option_reader(option, default_value) option_writer(option) end end private # @!attribute [r] config # Holds the config attr_reader :_config public # Create a new instance of config # # It tries to find a suitable configuration file. If it doesn't find one # the config is empty and uses the defaults defined within a config class # # @param [String] file # Path where config file is stored. The file will be read by the # `config_engine`. # # @param [Object] config_engine (Psych) # The engine to read config file # # @raise [Exceptions::ConfigFileNotReadable] # If an avaiable config file could not be read by the config engine # # @return [AppConfig] # The config instance. If the resulting data structure created by the # config_engine does not respond to `:[]` an empty config object will be # created. def initialize( file: _available_config_file, config_engine: Psych, logger: FeduxOrgStdlib::Logging::Logger.new ) @logger = logger unless file logger.debug "No configuration file found at #{_allowed_config_file_paths.to_list}, using an empty config." @_config = HashWithIndifferentAccess.new return end begin yaml = Psych.safe_load(File.read(file)) rescue StandardError => e fail Exceptions::ConfigFileNotReadable, JSON.dump(message: e.message, file: file) end if yaml.respond_to? :[] @_config = HashWithIndifferentAccess.new(yaml.symbolize_keys) else @_config = HashWithIndifferentAccess.new end end # Lock the configuration def lock _config.freeze end # Output a string presentation of the configuration # # @return [String] # An formatted version of the configuration def to_s result = [] result << sprintf("%20s | %s", 'option', 'value') result << sprintf("%s + %s", '-' * 20, '-' * 80) self.class._options.each do |o| result << sprintf("%20s | %s", o, Array(public_send(o)).join(', ')) end result.join("\n") end private # The name of the config file # # @return [String] # The name of the config file. It defaults to `.yaml`. If # you want to use a different file name you need to overwrite this # method. def _config_file "#{_config_name}#{_config_file_suffix}" end # The suffix of the config file # # @return [String] # The suffix of the config file def _config_file_suffix '.yaml' end # The base name of the config # # @return [String] # This one returns the base name of the config file (without the file # extension). It uses the class name of the config class # # @example Determine the base name of the config # # class ClientConfig; end # # This will result in `client` as base name for the config file. def _config_name unless (name = _class_name.sub(/Config/, '').underscore.pluralize).blank? return name end fail Exceptions::ClassNameIsMissing, JSON.dump(klass: _class_name) end # The name of your application # # @return [String] # This will strip of the class part of fully qualified class name and # converted it to a path. # # @example Determine application name # # class MyApplication::MyConfig; end # # This will be converted to # # my_application def _application_name _module_name.underscore end # The paths where to look for the config file # # @return [Array] # A list of paths where the config object should look for its config # file. def _allowed_config_file_paths [ ::File.expand_path(::File.join(self.class.process_environment.fetch('HOME'), '.config', _application_name, _config_file)), ::File.expand_path(::File.join(self.class.process_environment.fetch('HOME'), format('.%s', _application_name), _config_file)), ::File.expand_path(::File.join(self.class.process_environment.fetch('HOME'), format('.%s', _config_file))), ::File.expand_path(::File.join(self.class.process_environment.fetch('HOME'), format('.%src', _config_name))), ::File.expand_path(::File.join('/etc', _application_name, _config_file)), # ::File.expand_path("../../../../files/#{config_file}", __FILE__), ] end def _class_name self.class.name.to_s.demodulize end def _module_name self.class.to_s.deconstantize end def _available_config_file _allowed_config_file_paths.find { |f| ::File.exists? f } end def self._reserved_key_words (methods | instance_methods | private_methods | private_instance_methods ) - (Class.methods | Class.private_methods ) | [:to_s] end end end