lib/ultra_settings/configuration.rb in ultra_settings-0.0.1.rc1 vs lib/ultra_settings/configuration.rb in ultra_settings-1.0.0

- old
+ new

@@ -1,34 +1,41 @@ # frozen_string_literal: true -require "singleton" - module UltraSettings class Configuration include Singleton ALLOWED_NAME_PATTERN = /\A[a-z_][a-zA-Z0-9_]*\z/ ALLOWED_TYPES = [:string, :symbol, :integer, :float, :boolean, :datetime, :array].freeze - class_attribute :environment_variables_disabled, instance_accessor: false, default: false - - class_attribute :runtime_settings_disabled, instance_accessor: false, default: false - - class_attribute :yaml_config_disabled, instance_accessor: false, default: false - - class_attribute :env_var_delimiter, instance_accessor: false, default: "_" - - class_attribute :setting_delimiter, instance_accessor: false, default: "." - - class_attribute :env_var_upcase, instance_accessor: false, default: true - - class_attribute :setting_upcase, instance_accessor: false, default: false - - class_attribute :yaml_config_directory, instance_accessor: false, default: "config" - class << self - def define(name, type: :string, default: nil, default_if: nil, static: false, setting: nil, env_var: nil, yaml_key: nil) + # Define a field on the configuration. This will create a getter method for the field. + # The field value will be read from the environment, runtime settings, or a YAML file + # and coerced to the specified type. Empty strings will be converted to nil. + # + # @param name [Symbol, String] The name of the field. + # @param type [Symbol] The type of the field. Valid types are :string, :symbol, :integer, + # :float, :boolean, :datetime, and :array. The default type is :string. The :array type + # will return an array of strings. + # @param description [String] A description of the field. + # @param default [Object] The default value of the field. + # @param default_if [Proc, Symbol] A proc that returns true if the default value should be used. + # By default, the default value will be used if the field evaluates to nil. You can also set + # this to a symbol with the name of an instance method to call. + # @param static [Boolean] If true, the field value should never be changed. This is useful for + # fields that are used at startup to set static values in the application. Static field cannot + # be read from runtime settings. + # @param runtime_setting [String, Symbol] The name of the runtime setting to use for the field. + # By default this will be the underscored name of the class plus a dot plus the field name + # (i.e. MyServiceConfiguration#foo becomes "my_service.foo"). + # @param env_var [String, Symbol] The name of the environment variable to use for the field. + # By default this will be the underscored name of the class plus an underscore plus the field name + # all in uppercase (i.e. MyServiceConfiguration#foo becomes "MY_SERVICE_FOO"). + # @param yaml_key [String, Symbol] The name of the YAML key to use for the field. By default + # this is the name of the field. + # @return [void] + def field(name, type: :string, description: nil, default: nil, default_if: nil, static: nil, runtime_setting: nil, env_var: nil, yaml_key: nil) name = name.to_s type = type.to_sym static = !!static unless name.match?(ALLOWED_NAME_PATTERN) @@ -37,164 +44,444 @@ unless ALLOWED_TYPES.include?(type) raise ArgumentError.new("Invalid type: #{type.inspect}") end - unless default_if.nil? || default_if.is_a?(Proc) - raise ArgumentError.new("default_if must be a Proc") + unless default_if.nil? || default_if.is_a?(Proc) || default_if.is_a?(Symbol) + raise ArgumentError.new("default_if must be a Proc or Symbol") end defined_fields[name] = Field.new( name: name, type: type, + description: description, default: default, default_if: default_if, - env_var: env_var, - setting_name: setting, - yaml_key: yaml_key, - env_var_prefix: env_var_prefix, - env_var_upcase: env_var_upcase, - setting_prefix: setting_prefix, - setting_upcase: setting_upcase + env_var: construct_env_var(name, env_var), + runtime_setting: (static ? nil : construct_runtime_setting(name, runtime_setting)), + yaml_key: construct_yaml_key(name, yaml_key), + static: static ) class_eval <<-RUBY, __FILE__, __LINE__ + 1 # rubocop:disable Security/Eval def #{name} - __get_value__(#{name.inspect}, #{static.inspect}) + __get_value__(#{name.inspect}) end RUBY if type == :boolean alias_method "#{name}?", name end end + # List of the defined fields for the configuration. + # + # @return [Array<UltraSettings::Field>] + def fields + defined_fields.values + end + + # Check if the field is defined on the configuration. + # + # @param name [Symbol, String] The name of the field. + # @return [Boolean] + def include?(name) + name = name.to_s + return true if defined_fields.include?(name) + + if superclass <= Configuration + superclass.include?(name) + else + false + end + end + + # Override the default environment variable prefix. By default this wil be + # the underscored name of the class plus an underscore + # (i.e. MyServiceConfiguration has a prefix of "MY_SERVICE_"). + # + # @param value [String] + # @return [void] def env_var_prefix=(value) @env_var_prefix = value&.to_s end + # Get the environment variable prefix. + # + # @return [String] def env_var_prefix unless defined?(@env_var_prefix) @env_var_prefix = default_env_var_prefix end @env_var_prefix end - def setting_prefix=(value) - @setting_prefix = value&.to_s + # Override the default runtime setting prefix. By default this wil be + # the underscored name of the class plus a dot (i.e. MyServiceConfiguration + # has a prefix of "my_service."). + # + # @param value [String] + # @return [void] + def runtime_setting_prefix=(value) + @runtime_setting_prefix = value&.to_s end - def setting_prefix - unless defined?(@setting_prefix) - @setting_prefix = default_setting_prefix + # Get the runtime setting prefix. + # + # @return [String] + def runtime_setting_prefix + unless defined?(@runtime_setting_prefix) + @runtime_setting_prefix = default_runtime_setting_prefix end - @setting_prefix + @runtime_setting_prefix end + # Override the default YAML config path. By default this will be the + # file matching the underscored name of the class in the configuration + # directory (i.e. MyServiceConfiguration has a default config path of + # "my_service.yml"). + # + # @param value [String, Pathname] + # @return [void] def configuration_file=(value) value = Pathname.new(value) if value.is_a?(String) - value = Rails.root + value if value && !value.absolute? @configuration_file = value end + # Get the YAML file path. + # + # @return [Pathname, nil] def configuration_file unless defined?(@configuration_file) @configuration_file = default_configuration_file end - @configuration_file + return nil? unless @configuration_file + + path = @configuration_file + if path.relative? && yaml_config_path + path = yaml_config_path.join(path) + end + path.expand_path end + # Set to true to disable loading configuration from environment variables. + # + # @param value [Boolean] + # @return [void] + def environment_variables_disabled=(value) + set_inheritable_class_attribute(:@environment_variables_disabled, !!value) + end + + # Check if loading configuration from environment variables is disabled. + # + # @return [Boolean] + def environment_variables_disabled? + get_inheritable_class_attribute(:@environment_variables_disabled, false) + end + + # Set to true to disable loading configuration from runtime settings. + # + # @param value [Boolean] + # @return [void] + def runtime_settings_disabled=(value) + set_inheritable_class_attribute(:@runtime_settings_disabled, !!value) + end + + # Check if loading configuration from runtime settings is disabled. + # + # @return [Boolean] + def runtime_settings_disabled? + get_inheritable_class_attribute(:@runtime_settings_disabled, false) + end + + # Set to true to disable loading configuration from YAML files. + # + # @param value [Boolean] + # @return [void] + def yaml_config_disabled=(value) + set_inheritable_class_attribute(:@yaml_config_disabled, !!value) + end + + # Check if loading configuration from YAML files is disabled. + # + # @return [Boolean] + def yaml_config_disabled? + get_inheritable_class_attribute(:@yaml_config_disabled, false) + end + + # Set the environment variable delimiter used to construct the environment + # variable name for a field. By default this is an underscore. + # + # @param value [String] + def env_var_delimiter=(value) + set_inheritable_class_attribute(:@env_var_delimiter, value.to_s) + end + + # Get the environment variable delimiter. + # + # @return [String] + def env_var_delimiter + get_inheritable_class_attribute(:@env_var_delimiter, "_") + end + + # Set the runtime setting delimiter used to construct the runtime setting + # name for a field. By default this is a dot. + # + # @param value [String] + # @return [void] + def runtime_setting_delimiter=(value) + set_inheritable_class_attribute(:@runtime_setting_delimiter, value.to_s) + end + + # Get the runtime setting delimiter. + # + # @return [String] + def runtime_setting_delimiter + get_inheritable_class_attribute(:@runtime_setting_delimiter, ".") + end + + # Set to true to upcase the environment variable name for a field. This + # is true by default. + # + # @param value [Boolean] + # @return [void] + def env_var_upcase=(value) + set_inheritable_class_attribute(:@env_var_upcase, !!value) + end + + # Check if the environment variable name for a field should be upcased. + # + # @return [Boolean] + def env_var_upcase? + get_inheritable_class_attribute(:@env_var_upcase, true) + end + + # Set to true to upcase the runtime setting name for a field. This + # is false by default. + # + # @param value [Boolean] + # @return [void] + def runtime_setting_upcase=(value) + set_inheritable_class_attribute(:@runtime_setting_upcase, !!value) + end + + # Check if the runtime setting name for a field should be upcased. + # + # @return [Boolean] + def runtime_setting_upcase? + get_inheritable_class_attribute(:@runtime_setting_upcase, false) + end + + # Set the directory where YAML files will be loaded from. By default this + # is the current working directory. + # + # @param value [String, Pathname] + # @return [void] + def yaml_config_path=(value) + value = Pathname.new(value) if value.is_a?(String) + value = value.expand_path if value&.relative? + set_inheritable_class_attribute(:@yaml_config_path, value) + end + + # Get the directory where YAML files will be loaded from. + # + # @return [Pathname, nil] + def yaml_config_path + get_inheritable_class_attribute(:@yaml_config_path, nil) + end + + # Set the environment namespace used in YAML file name. By default this + # is "development". Settings from the specific environment hash in the YAML + # file will be merged with base settings specified in the "shared" hash. + # + # @param value [String] + # @return [void] + def yaml_config_env=(value) + set_inheritable_class_attribute(:@yaml_config_env, value) + end + + # Get the environment namespace used in YAML file name. + # + # @return [String] + def yaml_config_env + get_inheritable_class_attribute(:@yaml_config_env, "development") + end + + # Override field values within a block. + # + # @param values [Hash<Symbol, Object>]] List of fields with the values they + # should return within the block. + # @return [Object] The value returned by the block. + def override!(values, &block) + instance.override!(values, &block) + end + + # Load the YAML file for this configuration and return the values for the + # current environment. + # + # @return [Hash] def load_yaml_config return nil unless configuration_file - return nil unless configuration_file.exist? + return nil unless configuration_file.exist? && configuration_file.file? - Rails.application.config_for(configuration_file) + YamlConfig.new(configuration_file, yaml_config_env).to_h end private def defined_fields unless defined?(@defined_fields) - @defined_fields = {} - if superclass < Configuration - superclass.send(:defined_fields).each do |name, field| - @defined_fields[name] = Field.new( - name: field.name, - type: field.type, - default: field.default, - default_if: field.default_if, - env_var: field.env_var, - setting_name: field.setting_name, - yaml_key: field.yaml_key, - env_var_prefix: env_var_prefix, - env_var_upcase: env_var_upcase, - setting_prefix: setting_prefix, - setting_upcase: setting_upcase - ) - end + fields = {} + if superclass <= Configuration + fields = superclass.send(:defined_fields).dup end + @defined_fields = fields end @defined_fields end def root_name - name.sub(/Configuration\z/, "") + name.sub(/Configuration\z/, "").split("::").collect do |part| + part.gsub(/([A-Z])(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { ($1 || $2) << "_" }.downcase + end.join("/") end + def set_inheritable_class_attribute(name, value) + instance_variable_set(name, value) + end + + def get_inheritable_class_attribute(name, default = nil) + if instance_variable_defined?(name) + instance_variable_get(name) + elsif self != Configuration + superclass.send(:get_inheritable_class_attribute, name, default) + else + default + end + end + def default_configuration_file - path = Pathname.new(yaml_config_directory) - path = Rails.root + path if defined?(Rails) && !path.absolute? - path.join(*"#{root_name.underscore}.yml".split("/")) + path = Pathname.new(yaml_config_path) + path.join(*"#{root_name}.yml".split("/")) end def default_env_var_prefix - prefix = root_name.underscore.gsub("/", env_var_delimiter) + env_var_delimiter - prefix = prefix.upcase if env_var_upcase + prefix = root_name.gsub("/", env_var_delimiter) + env_var_delimiter + prefix = prefix.upcase if env_var_upcase? prefix end - def default_setting_prefix - prefix = root_name.underscore.gsub("/", setting_delimiter) + setting_delimiter - prefix = prefix.upcase if setting_upcase + def default_runtime_setting_prefix + prefix = root_name.gsub("/", runtime_setting_delimiter) + runtime_setting_delimiter + prefix = prefix.upcase if runtime_setting_upcase? prefix end + + def construct_env_var(name, env_var) + return nil if env_var == false + return nil if environment_variables_disabled? && env_var.nil? + + env_var = nil if env_var == true + + if env_var.nil? + env_var = "#{env_var_prefix}#{name}" + env_var = env_var.upcase if env_var_upcase? + end + + env_var + end + + def construct_runtime_setting(name, runtime_setting) + return nil if runtime_setting == false + return nil if runtime_settings_disabled? && runtime_setting.nil? + + runtime_setting = nil if runtime_setting == true + + if runtime_setting.nil? + runtime_setting = "#{runtime_setting_prefix}#{name}" + runtime_setting = runtime_setting.upcase if runtime_setting_upcase? + end + + runtime_setting + end + + def construct_yaml_key(name, yaml_key) + return nil if yaml_key == false + return nil if yaml_config_disabled? && yaml_key.nil? + + yaml_key = nil if yaml_key == true + yaml_key = name if yaml_key.nil? + + yaml_key + end end def initialize @mutex = Mutex.new @memoized_values = {} + @override_values = {} end def [](name) send(name.to_s) if include?(name) end def include?(name) - self.class.send(:defined_fields).include?(name.to_s) + self.class.include?(name.to_s) end - private + def override!(values, &block) + save_val = @override_values[Thread.current.object_id] - def __get_value__(name, static) - if static && @memoized_values.include?(name) - return @memoized_values[name] + temp_values = (save_val || {}).dup + values.each do |key, value| + temp_values[key.to_s] = value end + begin + @mutex.synchronize do + @override_values[Thread.current.object_id] = temp_values + end + yield + ensure + @mutex.synchronize do + @override_values[Thread.current.object_id] = save_val + end + end + end + + def __source__(name) field = self.class.send(:defined_fields)[name] + source = field.source(env: ENV, settings: UltraSettings.__runtime_settings__, yaml_config: __yaml_config__) + source || :default + end + + private + + def __get_value__(name) + field = self.class.send(:defined_fields)[name] return nil unless field - if !Rails.application.initialized? && !static - raise UltraSettings::NonStaticValueError.new("Cannot access non-static field #{name} during initialization") + if field.static? && @memoized_values.include?(name) + return @memoized_values[name] end - env = ENV unless self.class.environment_variables_disabled? - settings = __runtime_settings__ unless static || self.class.runtime_settings_disabled? - yaml_config = __yaml_config__ unless self.class.yaml_config_disabled? + if @override_values[Thread.current.object_id]&.include?(name) + value = field.coerce(@override_values[Thread.current.object_id][name]) + else + env = ENV if field.env_var + settings = UltraSettings.__runtime_settings__ if field.runtime_setting + yaml_config = __yaml_config__ if field.yaml_key - value = field.value(yaml_config: yaml_config, env: env, settings: settings) + value = field.value(yaml_config: yaml_config, env: env, settings: settings) + end - if static + if __use_default?(value, field.default_if) + value = field.default + end + + if field.static? @mutex.synchronize do if @memoized_values.include?(name) value = @memoized_values[name] else @memoized_values[name] = value @@ -203,11 +490,23 @@ end value end - def __runtime_settings__ - SuperSettings + def __use_default?(value, default_if) + return true if value.nil? + + if default_if.is_a?(Proc) + default_if.call(value) + elsif default_if.is_a?(Symbol) + begin + send(default_if, value) + rescue NoMethodError + raise NoMethodError, "default_if method `#{default_if}' not defined for #{self.class.name}" + end + else + false + end end def __yaml_config__ @yaml_config ||= (self.class.load_yaml_config || {}) end