require 'singleton' require 'yaml' require 'erb' require 'hashie' class Setting class NotFound < RuntimeError; end class FileError < RuntimeError; end class AlreadyLoaded < RuntimeError; end include Singleton NUM_KLASS = if RUBY_VERSION.split(/\./)[0].to_i == 2 && RUBY_VERSION.split(/\./)[1].to_i >= 4 Integer else Fixnum end attr_reader :available_settings # This method can be called only once. # # Parameter hash looks like this: # # { :files => [ "file1.yml", "file2.yml", ...], # :path => "/var/www/apps/my-app/current/config/settings", # :local => true } # # If :local => true is set, we will load all *.yml files under :path/local directory # after all files in :files have been loaded. "Local" settings thus take precedence # by design. See README for more details. # def self.load(args = {}) raise AlreadyLoaded.new('Settings already loaded') if self.instance.loaded? self.instance.load(args) end def self.reload(args = {}) self.instance.load(args) end # In Method invocation syntax we collapse Hash values # and return a single value if 'default' is found among keys # or Hash has only one key/value pair. # # For example, if the YML data is: # tax: # default: 0.0 # california: 7.5 # # Then calling Setting.tax returns "0.0"" # # This is the preferred method of using settings class. # def self.method_missing(method, *args, &block) self.instance.value_for(method, args) do |v, args| self.instance.collapse_hashes(v, args) end end def self.respond_to?(method_name, include_private = false) self.instance.available_settings.has_key?(method_name.to_s.sub(/\?\z/, '')) || super end # In [] invocation syntax, we return settings value 'as is' without # Hash conversions. # # For example, if the YML data is: # tax: # default: 0.0 # california: 7.5 # # Then calling Setting['tax'] returns # { 'default' => "0.0", 'california' => "7.5"} def self.[](value) self.instance.value_for(value) end # DEPRECATED: Please use method accessors instead. def self.available_settings self.instance.available_settings end #================================================================= # Instance Methods #================================================================= def initialize @available_settings ||= Hashie::Mash.new end def has_key?(key) @available_settings.has_key?(key) || (key[-1,1] == '?' && @available_settings.has_key?(key.chop)) end def value_for(key, args = []) name = key.to_s raise NotFound.new("#{name} was not found") unless has_key?(name) bool = false if name[-1,1] == '?' name.chop! bool = true end v = @available_settings[name] if block_given? v = yield(v, args) end if v.is_a?(NUM_KLASS) && bool v.to_i > 0 else v end end # This method performs collapsing of the Hash settings values if the Hash # contains 'default' value, or just 1 element. def collapse_hashes(v, args) out = if v.is_a?(Hash) if args.empty? if v.has_key?("default") v['default'].nil? ? "" : v['default'] elsif v.keys.size == 1 v.values.first else v end else v[args.shift.to_s] end else v end if out.is_a?(Hash) && !args.empty? collapse_hashes(out, args) elsif out.is_a?(Hash) && out.has_key?('default') out['default'] else out end end def loaded? @loaded end def load(params) # reset settings hash @available_settings = Hashie::Mash.new @loaded = false files = [] path = params[:path] || Dir.pwd params[:files].each do |file| files << File.join(path, file) end if params[:local] files << Dir.glob(File.join(path, 'local', '*.yml')).sort end files.flatten.each do |file| begin # Ruby versions before 3.0.3 include Psych < 3.3.2, which does not include `unsafe_load`. In those versions, # `load` is the behavior we want (in later versions, `load` uses `safe_load`, which doesn't support aliases and # requires allowlisting classes used in files. if Psych::VERSION < '3.3.2' @available_settings.deep_merge!(YAML::load(ERB.new(IO.read(file)).result) || {}) if File.exists?(file) else @available_settings.deep_merge!(YAML::unsafe_load(ERB.new(IO.read(file)).result) || {}) if File.exists?(file) end rescue Exception => e raise FileError.new("Error parsing file #{file}, with: #{e.message}") end end @loaded = true @available_settings end end