lib/tty/config.rb
# frozen_string_literal: true File `config.rb` has 345 lines of code (exceeds 250 allowed). Consider refactoring.require "pathname" require_relative "config/version"require_relative "config/marshallers"require_relative "config/marshallers/ini_marshaller"require_relative "config/marshallers/json_marshaller"require_relative "config/marshallers/yaml_marshaller"require_relative "config/marshallers/toml_marshaller"require_relative "config/marshallers/hcl_marshaller"require_relative "config/marshallers/java_props_marshaller"require_relative "config/marshallers/xml_marshaller" module TTY # Responsible for managing application configuration # # @api publicClass `Config` has 42 methods (exceeds 20 allowed). Consider refactoring. class Config include Marshallers # Error raised when failed to load a dependency DependencyLoadError = Class.new(StandardError) # Error raised when key fails validation ReadError = Class.new(StandardError) # Error raised when issues writing configuration to a file WriteError = Class.new(StandardError) # Erorrr raised when setting unknown file extension UnsupportedExtError = Class.new(StandardError) # Error raised when validation assertion fails ValidationError = Class.new(StandardError) # Coerce a hash object into Config instance # # @return [TTY::Config] # # @api private def self.coerce(hash, &block) new(normalize_hash(hash), &block) end # Convert string keys via method # # @param [Hash] hash # the hash to normalize keys for # @param [Symbol] method # the method to use for converting keys # # @return [Hash{Symbol => Object}] # the converted hash # # @api private def self.normalize_hash(hash, method = :to_sym) hash.each_with_object({}) do |(key, val), acc| value = val.is_a?(::Hash) ? normalize_hash(val, method) : val acc[key.public_send(method)] = value end end # A collection of config paths # @api public attr_reader :location_paths # The key delimiter used for specifying deeply nested keys # @api public attr_reader :key_delim # The name of the configuration file without extension # @api public attr_accessor :filename # The name of the configuration file extension # @api public attr_reader :extname # The validations for this configuration # @api public attr_reader :validators # The prefix used for searching ENV variables # @api public attr_accessor :env_prefix # The string used to separate parts in ENV variable name # @api public attr_accessor :env_separator # Create a configuration instance # # @api public def initialize(settings = {}) @settings = settings @location_paths = [] @validators = {} @filename = "config" @extname = ".yml" @key_delim = "." @envs = {} @env_prefix = "" @env_separator = "_" @autoload_env = false @aliases = {} register_marshaller :yaml, Marshallers::YAMLMarshaller register_marshaller :json, Marshallers::JSONMarshaller register_marshaller :toml, Marshallers::TOMLMarshaller register_marshaller :ini, Marshallers::INIMarshaller register_marshaller :xml, Marshallers::XMLMarshaller register_marshaller :hcl, Marshallers::HCLMarshaller register_marshaller :jprops, Marshallers::JavaPropsMarshaller yield(self) if block_given? end # Set extension name # # @raise [TTY::Config::UnsupportedExtError] # # api public def extname=(name) unless extensions.include?(name) raise UnsupportedExtError, "Config file format `#{name}` is not supported." end @extname = name end # Add path to locations to search in # # @example # append_path(Dir.pwd) # # @param [String] path # the path to append # # @return [Array<String>] # # @api public def append_path(path) @location_paths << path end # Insert location path at the begining # # @example # prepend_path(Dir.pwd) # # @param [String] path # the path to prepend # # @return [Array<String>] # # @api public def prepend_path(path) @location_paths.unshift(path) end # Check if env variables are auto loaded # # @return [Boolean] # # @api public def autoload_env? @autoload_env == true end # Auto load env variables # # @api public def autoload_env @autoload_env = true end # Set a value for a composite key and overrides any existing keys # Keys are case-insensitive # # @example # set(:foo, :bar, :baz, value: 2) # # @example # set(:foo, :bar, :baz) { 2 } # # @example # set("foo.bar.baz", value: 2) # # @param [Array<String, Symbol>, String] keys # the nested key to set value for # @param [Object] value # the value to set # # @return [Object] # the set value # # @api public def set(*keys, value: nil, &block) assert_either_value_or_block(value, block) keys = convert_to_keys(keys) key = flatten_keys(keys) value_to_eval = block || value if validators.key?(key) if callable_without_params?(value_to_eval) value_to_eval = delay_validation(key, value_to_eval) else assert_valid(key, value) end end deepest_setting = deep_set(@settings, *keys[0...-1]) deepest_setting[keys.last] = value_to_eval deepest_setting[keys.last] end # Set a value for a composite key if not present already # # @example # set_if_empty(:foo, :bar, :baz, value: 2) # # @param [Array<String, Symbol>] keys # the keys to set value for # @param [Object] value # the value to set # # @return [Object, nil] # the set value or nil # # @api public def set_if_empty(*keys, value: nil, &block) keys = convert_to_keys(keys) return unless deep_fetch(@settings, *keys).nil? block ? set(*keys, &block) : set(*keys, value: value) end # Bind a key to ENV variable # # @example # set_from_env(:host) # set_from_env(:foo, :bar) { 'HOST' } # # @param [Array<String>] keys # the keys to bind to ENV variables # # @api public def set_from_env(*keys, &block) key = flatten_keys(keys) env_key = block.nil? ? key : block.() env_key = to_env_key(env_key) @envs[key.to_s.downcase] = env_key end # Convert config key to standard ENV var name # # @param [String] key # # @return [String] # # @api private def to_env_key(key) env_key = key.to_s.gsub(key_delim, env_separator).upcase if @env_prefix == "" env_key else "#{@env_prefix.to_s.upcase}#{env_separator}#{env_key}" end end # Fetch value under a composite key # # @example # fetch(:foo, :bar, :baz) # # @example # fetch("foo.bar.baz") # # @param [Array<String, Symbol>, String] keys # the keys to get value at # @param [Object] default # the default value # # @return [Object] # # @api publicMethod `fetch` has a Cognitive Complexity of 8 (exceeds 5 allowed). Consider refactoring. def fetch(*keys, default: nil, &block) # check alias real_key = @aliases[flatten_keys(keys)] keys = real_key.split(key_delim) if real_key keys = convert_to_keys(keys) env_key = autoload_env? ? to_env_key(keys[0]) : @envs[flatten_keys(keys)] # first try settings value = deep_fetch(@settings, *keys) # then try ENV var if value.nil? && env_key value = ENV[env_key] end # then try default value = block || default if value.nil? while callable_without_params?(value) value = value.() end value end # Merge in other configuration settings # # @param [Hash{Symbol => Object]] other_settings # # @return [Hash, nil] # the combined settings or nil # # @api public def merge(other_settings) return unless other_settings.respond_to?(:to_hash) @settings = deep_merge(@settings, other_settings) end # Append values to an already existing nested key # # @example # append(1, 2, to: %i[foo bar]) # # @param [Array<Object>] values # the values to append # @param [Array<String, Symbol] to # the nested key to append to # # @return [Array<Object>] # the values for a nested key # # @api public def append(*values, to: nil) keys = Array(to) set(*keys, value: Array(fetch(*keys)) + values) end # Remove a set of values from a nested key # # @example # remove(1, 2, from: :foo) # # @example # remove(1, 2, from: %i[foo bar]) # # @param [Array<Object>] values # the values to remove from a nested key # @param [Array<String, Symbol>, String] from # the nested key to remove values from # # @api public def remove(*values, from: nil) keys = Array(from) raise ArgumentError, "Need to set key to remove from" if keys.empty? set(*keys, value: Array(fetch(*keys)) - values) end # Delete a value from a nested key # # @example # delete(:foo, :bar, :baz) # # @example # delete(:unknown) { |key| "#{key} isn't set" } # # @param [Array<String, Symbol>] keys # the keys for a value deletion # # @yield [key] Invoke the block with a missing key # # @return [Object] # the deleted value(s) # # @api public def delete(*keys, &default) keys = convert_to_keys(keys) deep_delete(*keys, @settings, &default) end # Define an alias to a nested key # # @example # alias_setting(:foo, to: :bar) # # @param [Array<String>] keys # the alias key # # @api public def alias_setting(*keys, to: nil) flat_setting = flatten_keys(keys) alias_keys = Array(to) alias_key = flatten_keys(alias_keys) if alias_key == flat_setting raise ArgumentError, "Alias matches setting key" end if fetch(alias_key) raise ArgumentError, "Setting already exists with an alias " \ "'#{alias_keys.map(&:inspect).join(', ')}'" end @aliases[alias_key] = flat_setting end # Register a validation rule for a nested key # # @param [Array<String>] keys # a deep nested keys # @param [Proc] validator # the logic to use to validate given nested key # # @api public def validate(*keys, &validator) key = flatten_keys(keys) values = validators[key] || [] values << validator validators[key] = values end # Find configuration file matching filename and extension # # @api private def find_file @location_paths.each do |location_path| path = search_in_path(location_path) return path if path end nil end alias source_file find_file # Check if configuration file exists # # @return [Boolean] # # @api public def exist? !find_file.nil? end alias persisted? exist? # Find and read a configuration file. # # If the file doesn't exist or if there is an error loading it # the TTY::Config::ReadError will be raised. # # @param [String] file # the path to the configuration file to be read # # @param [String] format # the format to read configuration in # # @raise [TTY::Config::ReadError] # # @api public def read(file = find_file, format: :auto) if file.nil? raise ReadError, "No file found to read configuration from!" elsif !::File.exist?(file) raise ReadError, "Configuration file `#{file}` does not exist!" end set_file_metadata(file) ext = (format == :auto ? extname : ".#{format}") content = ::File.read(file) merge(unmarshal(content, ext: ext)) end # Write current configuration to a file. # # @example # write(force: true, create: true) # # @param [String] file # the file to write to # @param [Boolean] create # whether or not to create missing path directories, false by default # @param [Boolean] force # whether or not to overwrite existing configuration file, false by default # @param [String] format # the format name for the configuration file, :auto by defualt # @param [String] path # the custom path to use to write a file to # # @raise [TTY::Config::WriteError] # # @api public def write(file = find_file, create: false, force: false, format: :auto, path: nil) file = fullpath(file, path) check_can_write(file, force) set_file_metadata(file) ext = (format == :auto ? extname : ".#{format}") content = marshal(@settings, ext: ext) filepath = Pathname.new(file) create_missing_dirs(filepath, create) ::File.write(filepath, content) end # Set file name and extension # # @example # set_file_metadata("config.yml") # # @param [File] file # the file to set metadata for # # @api public def set_file_metadata(file) self.extname = ::File.extname(file) self.filename = ::File.basename(file, extname) end # Current configuration # # @api public def to_hash @settings.dup end alias to_h to_hash private # Ensure that value is set either through parameter or block # # @api private def assert_either_value_or_block(value, block) if value.nil? && block.nil? raise ArgumentError, "Need to set either value or block" elsif !(value.nil? || block.nil?) raise ArgumentError, "Can't set both value and block" end end # Check if object is a proc with no arguments # # @return [Boolean] # # @api private def callable_without_params?(object) object.respond_to?(:call) && (!object.respond_to?(:arity) || object.arity.zero?) end # Wrap callback in a proc object that includes validation # that will be performed at point when a new proc is invoked. # # @param [String] key # the key to set validation for # @param [Proc] callback # the callback to wrap # # @return [Proc] # # @api private def delay_validation(key, callback) -> do val = callback.() assert_valid(key, val) val end end # Check if key passes all registered validations for a key # # @param [String] key # the key to validate a value for # @param [Object] value # the value to check # # @api private def assert_valid(key, value) validators[key].each do |validator| validator.(key, value) end end # Set value under deeply nested keys # # The scan starts with the top level key and follows # a sequence of keys. In case where intermediate keys do # not exist, a new hash is created. # # @param [Hash] settings # # @param [Array<Object>] keys # the keys to nest # # @return [Hash] # the nested setting # # @api privateMethod `deep_set` has a Cognitive Complexity of 7 (exceeds 5 allowed). Consider refactoring. def deep_set(settings, *keys) return settings if keys.empty? key, *rest = *keys value = settings[key] if value.nil? && rest.empty? settings[key] = {} elsif value.nil? && !rest.empty? settings[key] = {} deep_set(settings[key], *rest) else # nested hash value present settings[key] = value deep_set(settings[key], *rest) end end # Convert key to an array of key elements # # @param [String, Array<String, Symbol>] keys # # @return [Array<String>] # # @api private def convert_to_keys(keys) first_key = keys[0] if first_key.to_s.include?(key_delim) first_key.split(key_delim) else keys.map(&:to_s) end end # Convert nested key from an array to a string # # @example # flatten_keys(%i[foo bar baz]) # => "foo.bar.baz" # # @param [Array<String, Symbol>] keys # the nested key to convert # # @return [String] # the delimited nested key # # @api private def flatten_keys(keys) first_key = keys[0] if first_key.to_s.include?(key_delim) first_key else keys.join(key_delim) end end # Fetch value under deeply nested keys with indiffernt key access # # @param [Hash] settings # the settings to search # @param [Array<Object>] keys # the nested key to look up # # @return [Object, nil] # the value or nil # # @api private def deep_fetch(settings, *keys) key, *rest = keys value = settings.fetch(key.to_s, settings[key.to_sym]) if value.nil? || rest.empty? value else deep_fetch(value, *rest) end end # Merge two deeply nested hash objects # # @param [Hash] this_hash # @param [Hash] other_hash # # @return [Hash] # the merged hash object # # @api privateMethod `deep_merge` has a Cognitive Complexity of 6 (exceeds 5 allowed). Consider refactoring. def deep_merge(this_hash, other_hash, &block) this_hash.merge(other_hash) do |key, this_val, other_val| if this_val.is_a?(::Hash) && other_val.is_a?(::Hash) deep_merge(this_val, other_val, &block) elsif block_given? block[key, this_val, other_val] else other_val end end end # Delete a deeply nested key # # @param [Array<String>] keys # the nested key to delete # @param [Hash{String => Object}] # the settings to delete key from # # @return [Object] # the deleted object(s) # # @api private def deep_delete(*keys, settings, &default) key, *rest = keys value = settings[key] if !rest.empty? && value.is_a?(::Hash) deep_delete(*rest, value, &default) elsif !value.nil? settings.delete(key) elsif default default.(key) end end # Search for a configuration file in a path # # @param [String] path # the path to search # # @return [String, nil] # the configuration file path or nil # # @api private def search_in_path(path) path = Pathname.new(path) extensions.each do |ext| if ::File.exist?(path.join("#{filename}#{ext}").to_s) return path.join("#{filename}#{ext}").to_s end end nil end # Create a full path to a configuration file # # @param [String] file # the configuration file # @param [String] path # the path to configuration file # # @return [String] # the full path to a file # # @api private def fullpath(file, path) if file.nil? dir = path || @location_paths.first || Dir.pwd ::File.join(dir, "#{filename}#{@extname}") elsif file && path ::File.join(path, ::File.basename(file)) else file end end # Check if a file can be written to # # @param [String] file # the configuration file # @param [Boolean] force # whether or not to force writing # # @raise [TTY::Config::WriteError] # # @return [nil] # # @api private def check_can_write(file, force) return unless file && ::File.exist?(file) if !force raise WriteError, "File `#{file}` already exists. " \ "Use :force option to overwrite." elsif !::File.writable?(file) raise WriteError, "Cannot write to #{file}." end end # Create any missing directories # # @param [Pathname] filepath # the file path # @param [Boolean] create # whether or not to create missing directories # # @raise [TTY::Config::WriteError] # # @return [nil] # # @api private def create_missing_dirs(filepath, create) if !filepath.dirname.exist? && !create raise WriteError, "Directory `#{filepath.dirname}` doesn't exist. " \ "Use :create option to create missing directories." else filepath.dirname.mkpath end end # Crate a marshaller instance based on the extension name # # @param [String] ext # the extension name # # @return [nil, Marshaller] # # @api private def create_marshaller(ext) marshaller = marshallers.find { |marsh| marsh.ext.include?(ext) } return nil if marshaller.nil? marshaller.new end # Unmarshal content into a hash object # # @param [String] content # the content to convert into a hash object # # @return [Hash{String => Object}] # # @api private def unmarshal(content, ext: nil) ext ||= extname if marshaller = create_marshaller(ext) marshaller.unmarshal(content) else raise ReadError, "Config file format `#{ext}` is not supported." end end # Marshal hash object into a configuration file content # # @param [Hash{String => Object}] object # the object to convert to string # # @return [String] # # @api private def marshal(object, ext: nil) ext ||= extname if marshaller = create_marshaller(ext) marshaller.marshal(object) else raise WriteError, "Config file format `#{ext}` is not supported." end end end # Configend # TTY