require 'pdk' module PDK class Config class Namespace # @param value [String] the new name of this namespace. attr_writer :name # @return [String] the path to the file associated with the contents of # this namespace. attr_reader :file # @return [self] the parent namespace of this namespace. attr_accessor :parent # Initialises the PDK::Config::Namespace object. # # @param name [String] the name of the namespace (defaults to nil). # @param params [Hash{Symbol => Object}] keyword parameters for the # method. # @option params [String] :file the path to the file associated with the # contents of the namespace (defaults to nil). # @option params [self] :parent the parent {self} that this namespace is # a child of (defaults to nil). # @option params [self] :persistent_defaults whether default values should be persisted # to disk when evaluated. By default they are not persisted to disk. This is typically # used for settings which a randomly generated, instead of being deterministic, e.g. analytics user-id # @param block [Proc] a block that is evaluated within the new instance. def initialize(name = nil, file: nil, parent: nil, persistent_defaults: false, &block) @file = PDK::Util::Filesystem.expand_path(file) unless file.nil? @settings = {} @name = name.to_s @parent = parent @persistent_defaults = persistent_defaults @mounts = {} @loaded_from_file = false @read_only = false instance_eval(&block) if block end # Pre-configure a value in the namespace. # # Allows you to specify validators and a default value for value in the # namespace (see PDK::Config::Value#initialize). # # @param key [String,Symbol] the name of the value. # @param block [Proc] a block that is evaluated within the new [self]. # # @return [nil] def setting(key, &block) @settings[key.to_s] ||= default_setting_class.new(key.to_s, self) @settings[key.to_s].instance_eval(&block) if block end # Mount a provided [self] (or subclass) into the namespace. # # @param key [String,Symbol] the name of the namespace to be mounted. # @param obj [self] the namespace to be mounted. # @param block [Proc] a block to be evaluated within the instance of the # newly mounted namespace. # # @raise [ArgumentError] if the object to be mounted is not a {self} or # subclass thereof. # # @return [self] the mounted namespace. def mount(key, obj, &block) raise ArgumentError, 'Only PDK::Config::Namespace objects can be mounted into a namespace' unless obj.is_a?(PDK::Config::Namespace) obj.parent = self obj.name = key.to_s obj.instance_eval(&block) if block @mounts[key.to_s] = obj end # Create and mount a new child namespace. # # @param name [String,Symbol] the name of the new namespace. # @param block [Proc] def namespace(name, &block) mount(name, PDK::Config::Namespace.new, &block) end # Get the value of the named key. # # If there is a value for that key, return it. If not, follow the logic # described in {#default_config_value} to determine the default value to # return. # # @note Unlike a Ruby Hash, this will not return `nil` in the event that # the key does not exist (see #fetch). # # @param key [String,Symbol] the name of the value to retrieve. # # @return [Object] the requested value. def [](key) # Check if it's a mount first... return @mounts[key.to_s] unless @mounts[key.to_s].nil? # Check if it's a setting, otherwise nil return nil if settings[key.to_s].nil? return settings[key.to_s].value unless settings[key.to_s].value.nil? # Duplicate arrays and hashes so that they are isolated from changes being made default_value = PDK::Util.deep_duplicate(settings[key.to_s].default) return default_value if default_value.nil? || !@persistent_defaults # Persist the default value settings[key.to_s].value = default_value save_data default_value end # Get the value of the named key or the provided default value if not # present. Note that this does not trigger persistent defaults # # This differs from {#[]} in an important way in that it allows you to # return a default value, which is not possible using `[] || default` as # non-existent values when accessed normally via {#[]} will be defaulted # to a new Hash. # # @param key [String,Symbol] the name of the value to fetch. # @param default_value [Object] the value to return if the namespace does # not contain the requested value. # # @return [Object] the requested value. def fetch(key, default_value) # Check if it's a mount first... return @mounts[key.to_s] unless @mounts[key.to_s].nil? # Check if it's a setting, otherwise default_value return default_value if settings[key.to_s].nil? # Check if has a value, otherwise default_value settings[key.to_s].value.nil? ? default_value : settings[key.to_s].value end # After the value has been set in memory, the value will then be # persisted to disk. # # @param key [String,Symbol] the name of the configuration value. # @param value [Object] the value of the configuration value. # # @return [nil] def []=(key, value) # You can't set the value of a mount raise ArgumentError, 'Namespace mounts can not be set a value' unless @mounts[key.to_s].nil? set_volatile_value(key, value) # Persist the change save_data end # Convert the namespace into a Hash of values, suitable for serialising # and persisting to disk. # # Child namespaces that are associated with their own files are excluded # from the Hash (as their values will be persisted to their own files) # and nil values are removed from the Hash. # # @return [Hash{String => Object}] the values from the namespace that # should be persisted to disk. def to_h new_hash = {} settings.each_pair { |k, v| new_hash[k] = v.value } @mounts.each_pair { |k, mount_point| new_hash[k] = mount_point.to_h if mount_point.include_in_parent? } new_hash.delete_if { |_k, v| v.nil? } # rubocop :disable Style/CollectionCompact new_hash end # Resolves all filtered settings, including child namespaces, fully namespaced and filling in default values. # # @param filter [String] Only resolve setting names which match the filter. See #be_resolved? for matching rules # @return [Hash{String => Object}] All resolved settings for example {'user.module_defaults.author' => 'johndoe'} def resolve(filter = nil) resolved = {} # Resolve the settings settings.each_value do |setting| setting_name = setting.qualified_name if be_resolved?(setting_name, filter) resolved[setting_name] = setting.value.nil? ? setting.default : setting.value end end # Resolve the mounts @mounts.each_value { |mount| resolved.merge!(mount.resolve(filter)) } resolved end # @return [Boolean] true if the namespace has a parent, otherwise false. def child_namespace? !parent.nil? end # Determines the fully qualified name of the namespace. # # If this is a child namespace, then fully qualified name for the # namespace will be ".". # # @return [String] the fully qualifed name of the namespace. def name child_namespace? ? [parent.name, @name].join('.') : @name end # Determines if the contents of the namespace should be included in the # parent namespace when persisting to disk. # # If the namespace has been mounted into a parent namespace and is not # associated with its own file on disk, then the values in the namespace # should be included in the parent namespace when persisting to disk. # # @return [Boolean] true if the values should be included in the parent # namespace. def include_in_parent? child_namespace? && file.nil? end # Disables the namespace, and child namespaces, from writing changes to disk. # Typically this is only needed for unit testing. # @api private def read_only! @read_only = true # pass the read_only! method as a block to the each_value method. This means that # for each value in the @mounts hash, the read_only! method will be called on that value. @mounts.each_value(&:read_only!) end private # Returns the object class to create settings with. Subclasses may override this to use specific setting classes # # @return [Class[PDK::Config::Setting]] # # @abstract # @private def default_setting_class PDK::Config::Setting end # Determines whether a setting name should be resolved using the filter # Returns true when filter is nil. # Returns true if the filter is exactly the same name as the setting. # Returns true if the name is a sub-key of the filter e.g. # Given a filter of user.module_defaults, `user.module_defaults.author` will return true, but `user.analytics.disabled` will return false. # # @param name [String] The setting name to test. # @param filter [String] The filter used to test on the name. # @return [Boolean] Whether the name passes the filter. def be_resolved?(name, filter = nil) return true if filter.nil? # If we're not filtering, this value should always be resolved return true if name == filter # If it's exactly the same name then it should be resolved name.start_with?("#{filter}.") # If name is a subkey of the filter then it should be resolved end # @abstract Subclass and override {#parse_file} to implement parsing logic # for a particular config file format. # # @param data [String] The content of the file to be parsed. # @param filename [String] The path to the file to be parsed. # # @yield [String, Object] the data to be loaded into the # namespace. def parse_file(_filename); end # @abstract Subclass and override {#serialize_data} to implement generating # logic for a particular config file format. # # @param data [Hash{String => Object}] the data stored in the namespace # # @return [String] the serialized contents of the namespace suitable for # writing to disk. def serialize_data(_data); end # @abstract Subclass and override {#create_missing_setting} to implement logic # when a setting is dynamically created, for example when attempting to # set the value of an unknown setting # # @param data [Hash{String => Object}] the data stored in the namespace # # @return [String] the serialized contents of the namespace suitable for # writing to disk. def create_missing_setting(key, initial_value = nil) # Need to use `@settings` and `@mounts` here to stop recursive calls return unless @mounts[key.to_s].nil? return unless @settings[key.to_s].nil? @settings[key.to_s] = default_setting_class.new(key.to_s, self, initial_value) end # Set the value of the named key. # # If the key has been pre-configured with {#value}, then the value of the # key will be validated against any validators that have been configured. # # @param key [String,Symbol] the name of the configuration value. # @param value [Object] the value of the configuration value. def set_volatile_value(key, value) # Need to use `settings` here to force the backing file to be loaded return create_missing_setting(key, value) if settings[key.to_s].nil? # Need to use `@settings` here to stop recursive calls from []= @settings[key.to_s].value = value end # Helper method to read files. # # @raise [PDK::Config::LoadError] if the file is removed during read. # @raise [PDK::Config::LoadError] if the user doesn't have the # permissions needed to read the file. # @return [String,nil] the contents of the file or nil if the file does # not exist. def load_data(filename) return if filename.nil? return unless PDK::Util::Filesystem.file?(filename) PDK::Util::Filesystem.read_file(filename) rescue Errno::ENOENT => e raise PDK::Config::LoadError, e.message rescue Errno::EACCES raise PDK::Config::LoadError, format('Unable to open %{file} for reading', file: filename) end # Persist the contents of the namespace to disk. # # Directories will be automatically created and the contents of the # namespace will be serialized automatically with {#serialize_data}. # # @raise [PDK::Config::LoadError] if one of the intermediary path components # exist but is not a directory. # @raise [PDK::Config::LoadError] if the user does not have the # permissions needed to write the file. # # @return [nil] def save_data return if file.nil? || @read_only PDK::Util::Filesystem.mkdir_p(File.dirname(file)) PDK::Util::Filesystem.write_file(file, serialize_data(to_h)) rescue Errno::EACCES raise PDK::Config::LoadError, format('Unable to open %{file} for writing', file: file) rescue SystemCallError => e raise PDK::Config::LoadError, e.message end # Memoised accessor for the loaded data. # # @return [Hash PDK::Config::Setting>] the contents of the namespace. def settings return @settings if @loaded_from_file @loaded_from_file = true return @settings if file.nil? parse_file(file) do |key, parsed_setting| # Create a settings chain if a setting already exists parsed_setting.previous_setting = @settings[key] unless @settings[key].nil? @settings[key] = parsed_setting end @settings end end end end