150 lines
4.6 KiB
Ruby
150 lines
4.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'yaml'
|
|
require 'ostruct'
|
|
require_relative 'config_loader/errors'
|
|
|
|
# ConfigLoader provides environment-aware configuration loading from YAML files.
|
|
#
|
|
# This class demonstrates guardrails-compliant patterns:
|
|
# - Environment-based configuration separation
|
|
# - Clear error handling with custom exceptions
|
|
# - Immutable configuration objects via OpenStruct
|
|
#
|
|
# @example Basic usage
|
|
# ENV['APP_ENV'] = 'production'
|
|
# config = ConfigLoader.load
|
|
# config.database.host # => 'prod-db.example.com'
|
|
#
|
|
# @example Explicit environment
|
|
# config = ConfigLoader.load(environment: 'test')
|
|
# config.database.host # => 'localhost'
|
|
#
|
|
class ConfigLoader
|
|
# Valid environment names
|
|
VALID_ENVIRONMENTS = %w[production test development].freeze
|
|
|
|
# Default environment when APP_ENV is not set
|
|
DEFAULT_ENVIRONMENT = 'development'
|
|
|
|
# Required top-level configuration keys
|
|
REQUIRED_KEYS = %w[app_name version database].freeze
|
|
|
|
class << self
|
|
# Load configuration for the specified or current environment
|
|
#
|
|
# @param environment [String, nil] override environment (defaults to APP_ENV)
|
|
# @param config_dir [String, nil] custom config directory path
|
|
# @return [OpenStruct] configuration object with nested accessors
|
|
# @raise [ConfigNotFoundError] if config file does not exist
|
|
# @raise [InvalidConfigError] if config file is invalid or missing required keys
|
|
def load(environment: nil, config_dir: nil)
|
|
env = resolve_environment(environment)
|
|
dir = config_dir || default_config_dir
|
|
file_path = File.join(dir, "#{env}.yaml")
|
|
|
|
validate_file_exists!(file_path, env)
|
|
config_hash = load_yaml_file(file_path, env)
|
|
validate_config!(config_hash, env)
|
|
|
|
hash_to_openstruct(config_hash)
|
|
end
|
|
|
|
# Returns the current environment based on APP_ENV
|
|
#
|
|
# @return [String] current environment name
|
|
def current_environment
|
|
resolve_environment(nil)
|
|
end
|
|
|
|
private
|
|
|
|
# Resolve the environment from parameter or ENV
|
|
#
|
|
# @param environment [String, nil] explicit environment or nil
|
|
# @return [String] resolved environment name
|
|
def resolve_environment(environment)
|
|
env = environment || ENV.fetch('APP_ENV', DEFAULT_ENVIRONMENT)
|
|
env.to_s.downcase.strip
|
|
end
|
|
|
|
# Default configuration directory path
|
|
#
|
|
# @return [String] path to config directory
|
|
def default_config_dir
|
|
File.expand_path('../config', __dir__)
|
|
end
|
|
|
|
# Validate that the configuration file exists
|
|
#
|
|
# @param file_path [String] path to config file
|
|
# @param environment [String] environment name for error reporting
|
|
# @raise [ConfigNotFoundError] if file does not exist
|
|
def validate_file_exists!(file_path, environment)
|
|
return if File.exist?(file_path)
|
|
|
|
raise ConfigLoader::ConfigNotFoundError, environment
|
|
end
|
|
|
|
# Load and parse YAML file
|
|
#
|
|
# @param file_path [String] path to YAML file
|
|
# @param environment [String] environment name for error reporting
|
|
# @return [Hash] parsed configuration hash
|
|
# @raise [InvalidConfigError] if YAML is malformed
|
|
def load_yaml_file(file_path, environment)
|
|
content = File.read(file_path)
|
|
parsed = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
|
|
unless parsed.is_a?(Hash)
|
|
raise ConfigLoader::InvalidConfigError.new(
|
|
environment,
|
|
'Configuration must be a YAML hash/mapping'
|
|
)
|
|
end
|
|
|
|
parsed
|
|
rescue Psych::SyntaxError => e
|
|
raise ConfigLoader::InvalidConfigError.new(
|
|
environment,
|
|
"YAML syntax error: #{e.message}"
|
|
)
|
|
end
|
|
|
|
# Validate configuration has all required keys
|
|
#
|
|
# @param config [Hash] configuration hash
|
|
# @param environment [String] environment name for error reporting
|
|
# @raise [InvalidConfigError] if required keys are missing
|
|
def validate_config!(config, environment)
|
|
missing_keys = REQUIRED_KEYS - config.keys.map(&:to_s)
|
|
|
|
return if missing_keys.empty?
|
|
|
|
raise ConfigLoader::InvalidConfigError.new(
|
|
environment,
|
|
"Missing required keys: #{missing_keys.join(', ')}"
|
|
)
|
|
end
|
|
|
|
# Recursively convert a hash to OpenStruct for dot notation access
|
|
#
|
|
# @param hash [Hash] hash to convert
|
|
# @return [OpenStruct] nested OpenStruct object
|
|
def hash_to_openstruct(hash)
|
|
OpenStruct.new(
|
|
hash.transform_values do |value|
|
|
case value
|
|
when Hash
|
|
hash_to_openstruct(value)
|
|
when Array
|
|
value.map { |item| item.is_a?(Hash) ? hash_to_openstruct(item) : item }
|
|
else
|
|
value
|
|
end
|
|
end
|
|
)
|
|
end
|
|
end
|
|
end
|