├── lib ├── .DS_Store ├── hati_config │ ├── version.rb │ ├── errors.rb │ ├── type_map.rb │ ├── hati_configuration.rb │ ├── team.rb │ ├── environment.rb │ ├── remote_loader.rb │ ├── type_checker.rb │ ├── configuration.rb │ ├── encryption.rb │ ├── schema.rb │ ├── cache.rb │ └── setting.rb └── hati_config.rb ├── spec ├── .DS_Store ├── spec_helper.rb ├── support │ ├── dummy.rb │ └── yaml_file.rb ├── unit │ └── hati_config │ │ ├── errors_spec.rb │ │ ├── type_map_spec.rb │ │ ├── type_checker_spec.rb │ │ ├── setting_encryption_spec.rb │ │ ├── team_spec.rb │ │ ├── remote_loader_spec.rb │ │ ├── setting_spec.rb │ │ ├── schema_spec.rb │ │ ├── environment_spec.rb │ │ ├── configuration_spec.rb │ │ ├── cache_spec.rb │ │ └── encryption_spec.rb └── integration │ └── hati_config │ └── class_level │ ├── local_configs_spec.rb │ ├── flat_spec.rb │ ├── deep_nested_spec.rb │ └── nested_spec.rb ├── CHANGELOG.md ├── bin ├── setup └── console ├── Rakefile ├── Gemfile ├── LICENSE ├── LICENSE.txt ├── .rubocop.yml ├── .gitignore ├── hati-config.gemspec ├── CODE_OF_CONDUCT.md ├── OVERVIEW.md └── README_NEW.md /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackico-ai/ruby-hati-config/HEAD/lib/.DS_Store -------------------------------------------------------------------------------- /spec/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackico-ai/ruby-hati-config/HEAD/spec/.DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.0] - 2024-09-12 4 | 5 | - Preview Candidate 0.1.0 6 | -------------------------------------------------------------------------------- /lib/hati_config/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiConfig 4 | VERSION = '0.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'hati_config' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'irb' 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'bundler-audit', '~> 0.9.2' 8 | gem 'fasterer', '~> 0.11.0' 9 | gem 'overcommit' 10 | gem 'pry' 11 | gem 'rake' 12 | gem 'rspec', '~> 3.0' 13 | gem 'rubocop', '~> 1.21' 14 | gem 'rubocop-rake' 15 | gem 'rubocop-rspec', require: false 16 | gem 'webmock', '~> 3.19' 17 | -------------------------------------------------------------------------------- /lib/hati_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hati_config/version' 4 | require 'hati_config/errors' 5 | require 'hati_config/type_map' 6 | require 'hati_config/type_checker' 7 | require 'hati_config/remote_loader' 8 | require 'hati_config/environment' 9 | require 'hati_config/team' 10 | require 'hati_config/schema' 11 | require 'hati_config/cache' 12 | require 'hati_config/encryption' 13 | require 'hati_config/setting' 14 | require 'hati_config/configuration' 15 | require 'hati_config/hati_configuration' 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'hati_config' 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = '.rspec_status' 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | 17 | Dir[File.join('./spec/support/**/*.rb')].each { |f| require f } 18 | config.include YamlFile 19 | config.include Dummy 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/dummy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Dummy module provides utility methods for creating dummy classes and modules 4 | # for testing purposes with HatiConfiguration. 5 | module Dummy 6 | # Creates a dummy class with the specified name and extends it with 7 | # HatiConfiguration::Isolated. 8 | # 9 | # @param name [String] The name of the class to be created. 10 | # @example Creating a dummy class 11 | # support_dummy_class("TestClass") 12 | # expect(TestClass).to be_a(Class) 13 | def support_dummy_class(name) 14 | stub_const(name, Class.new { extend HatiConfiguration::Local }) 15 | end 16 | 17 | # Creates a dummy module that extends HatiConfiguration. 18 | # 19 | # @return [Module] A new module that extends HatiConfiguration. 20 | # @example Creating a dummy module 21 | # dummy_module = support_dummy_module 22 | # expect(dummy_module).to respond_to(:configure) 23 | def support_dummy_module 24 | Module.new { extend HatiConfiguration } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hackico.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hackico.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/support/yaml_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tempfile' 4 | 5 | # YamlFile module provides utility methods for handling YAML files in tests. 6 | module YamlFile 7 | # Creates a temporary YAML file with predefined content and yields the file to a block. 8 | # 9 | # This method generates a temporary file named "config.yml" containing a YAML representation 10 | # of a hash with default settings. The block provided to this method will receive the temporary 11 | # file as an argument for further processing. 12 | # 13 | # @yieldparam temp_file [Tempfile] The temporary file containing the YAML data. 14 | # @yieldreturn [void] Returns nothing. 15 | # 16 | # @example Using the support_yaml_file_tempfile method 17 | # support_yaml_file_tempfile do |file| 18 | # # Perform operations with the temporary YAML file 19 | # puts file.read 20 | # end 21 | def support_yaml_file_tempfile 22 | Tempfile.create(['config', '.yml']) do |temp_file| 23 | temp_file.write({ username: 'admin', max_connections: 10 }.to_yaml) 24 | temp_file.rewind 25 | 26 | yield temp_file 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | NewCops: enable 7 | TargetRubyVersion: 3.0 8 | SuggestExtensions: false 9 | 10 | # Metrics configurations 11 | 12 | Metrics/MethodLength: 13 | Max: 35 14 | 15 | Metrics/AbcSize: 16 | Max: 65 17 | 18 | Metrics/CyclomaticComplexity: 19 | Max: 20 20 | 21 | Metrics/PerceivedComplexity: 22 | Max: 20 23 | 24 | # Layout configurations 25 | Layout/LineLength: 26 | Max: 120 27 | Exclude: 28 | - 'spec/**/*' 29 | 30 | # Style configurations 31 | Style/Documentation: 32 | Enabled: false 33 | 34 | Style/OptionalBooleanParameter: 35 | Enabled: false 36 | 37 | # Lint configurations 38 | Lint/DuplicateMethods: 39 | Enabled: false # We use method overloading pattern in some cases 40 | 41 | # RSpec configurations 42 | Metrics/BlockLength: 43 | Exclude: 44 | - 'spec/**/*' 45 | - '*.gemspec' 46 | 47 | RSpec/MultipleExpectations: 48 | Max: 5 49 | 50 | RSpec/ExampleLength: 51 | Max: 20 52 | 53 | RSpec/MultipleMemoizedHelpers: 54 | Max: 10 55 | 56 | RSpec/NestedGroups: 57 | Max: 5 58 | 59 | RSpec/ReceiveMessages: 60 | Enabled: false 61 | 62 | # Security configurations 63 | Security/MarshalLoad: 64 | Exclude: 65 | - 'lib/hati_config/cache.rb' # Required for caching complex objects 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | 58 | # Specs 59 | .rspec_status 60 | 61 | # libs 62 | Gemfile.lock -------------------------------------------------------------------------------- /hati-config.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'hati_config/version' 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = 'hati-config' 10 | spec.version = '0.1.0' 11 | spec.authors = ['Marie Giy'] 12 | spec.email = %w[giy.mariya@gmail.com] 13 | spec.license = 'MIT' 14 | 15 | spec.summary = 'Ruby configuration management for distributed systems and multi-team environments.' 16 | spec.description = 'A practical approach to configuration management with type safety, team isolation, ' \ 17 | 'environment inheritance, encryption, and remote sources. Designed for teams dealing ' \ 18 | 'with configuration complexity at scale.' 19 | spec.homepage = "https://github.com/hackico-ai/#{spec.name}" 20 | 21 | spec.required_ruby_version = '>= 3.0.0' 22 | 23 | spec.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'hati-config.gemspec', 'lib/**/*'] 24 | spec.bindir = 'bin' 25 | spec.executables = [] 26 | spec.require_paths = ['lib'] 27 | 28 | spec.metadata['repo_homepage'] = spec.homepage 29 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 30 | 31 | spec.metadata['homepage_uri'] = spec.homepage 32 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md" 33 | spec.metadata['source_code_uri'] = spec.homepage 34 | spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" 35 | 36 | spec.metadata['rubygems_mfa_required'] = 'true' 37 | 38 | spec.add_dependency 'aws-sdk-s3', '~> 1.0' 39 | spec.add_dependency 'bigdecimal', '~> 3.0' 40 | spec.add_dependency 'connection_pool', '~> 2.4' 41 | spec.add_dependency 'redis', '~> 5.0' 42 | end 43 | -------------------------------------------------------------------------------- /lib/hati_config/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # HatiConfig module provides functionality for managing HatiConfig features. 4 | module HatiConfig 5 | # Custom error raised when a setting type does not match the expected type. 6 | # 7 | # @example Raising an SettingTypeError 8 | # raise SettingTypeError.new(:int, "string") 9 | # # => raises SettingTypeError with message 10 | # # "Expected: . Given: \"string\" which is class." 11 | class SettingTypeError < TypeError 12 | # Initializes a new SettingTypeError. 13 | # 14 | # @param type [Symbol] The expected type. 15 | # @param val [Object] The value that was provided. 16 | def initialize(type, val) 17 | super(type_error_msg(type, val)) 18 | end 19 | 20 | # Generates the error message for the exception. 21 | # 22 | # @param type [Symbol] The expected type. 23 | # @param val [Object] The value that was provided. 24 | # @return [String] The formatted error message. 25 | def type_error_msg(type, val) 26 | "Expected: <#{type}>. Given: #{val.inspect} which is <#{val.class}> class." 27 | end 28 | end 29 | 30 | # Custom error raised when a type definition is missing. 31 | # 32 | # @example Raising a TypeCheckerError 33 | # raise TypeCheckerError.new(:unknown_type) 34 | # # => raises TypeCheckerError with message "No type Definition for: type" 35 | class TypeCheckerError < StandardError 36 | # Initializes a new TypeCheckerError. 37 | # 38 | # @param type [Symbol] The type that is missing a definition. 39 | def initialize(type) 40 | super(definition_error_msg(type)) 41 | end 42 | 43 | # Generates the error message for the exception. 44 | # 45 | # @param type [Symbol] The type that is missing a definition. 46 | # @return [String] The formatted error message. 47 | def definition_error_msg(type) 48 | "No type Definition for: <#{type}> type" 49 | end 50 | end 51 | 52 | # Custom error raised when data loading fails. 53 | class LoadDataError < StandardError; end 54 | end 55 | -------------------------------------------------------------------------------- /spec/unit/hati_config/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'HatiConfig::Errors' do 6 | describe 'SettingTypeError' do 7 | it 'sets the error message correctly' do 8 | error = HatiConfig::SettingTypeError.new(:int, 'string') 9 | expect(error.message).to eq('Expected: . Given: "string" which is class.') 10 | end 11 | 12 | it 'handles nil value correctly' do 13 | error = HatiConfig::SettingTypeError.new(:int, nil) 14 | expect(error.message).to eq('Expected: . Given: nil which is class.') 15 | end 16 | 17 | it 'handles array input correctly' do 18 | error = HatiConfig::SettingTypeError.new(:int, [1, 2, 3]) 19 | expect(error.message).to eq('Expected: . Given: [1, 2, 3] which is class.') 20 | end 21 | end 22 | 23 | describe 'TypeCheckerError' do 24 | it 'sets the error message correctly' do 25 | error = HatiConfig::TypeCheckerError.new(:unknown_type) 26 | expect(error.message).to eq('No type Definition for: type') 27 | end 28 | 29 | it 'handles known type with nil correctly' do 30 | error = HatiConfig::TypeCheckerError.new(nil) 31 | expect(error.message).to eq('No type Definition for: <> type') 32 | end 33 | 34 | it 'handles empty string type correctly' do 35 | error = HatiConfig::TypeCheckerError.new('') 36 | expect(error.message).to eq('No type Definition for: <> type') 37 | end 38 | end 39 | 40 | describe 'LoadDataError' do 41 | it 'sets the error message correctly' do 42 | error = HatiConfig::LoadDataError.new('Data loading failed') 43 | expect(error.message).to eq('Data loading failed') 44 | end 45 | 46 | it 'handles empty message correctly' do 47 | error = HatiConfig::LoadDataError.new('') 48 | expect(error.message).to eq('') 49 | end 50 | 51 | it 'handles nil message correctly' do 52 | error = HatiConfig::LoadDataError.new('HatiConfig::LoadDataError') 53 | expect(error.message).to eq('HatiConfig::LoadDataError') 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/unit/hati_config/type_map_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::TypeMap do 6 | describe '.get' do 7 | it { expect(described_class.get(:int)).to eq(Integer) } 8 | it { expect(described_class.get(:str)).to eq(String) } 9 | it { expect(described_class.get(:hash)).to eq(Hash) } 10 | it { expect(described_class.get(:array)).to eq(Array) } 11 | it { expect(described_class.get(:big_decimal)).to eq(BigDecimal) } 12 | it { expect(described_class.get(:float)).to eq(Float) } 13 | it { expect(described_class.get(:date)).to eq(Date) } 14 | it { expect(described_class.get(:true_class)).to eq(TrueClass) } 15 | it { expect(described_class.get(:false_class)).to eq(FalseClass) } 16 | it { expect(described_class.get(:unknown)).to be_nil } 17 | end 18 | 19 | context 'when defining TypeMap types' do 20 | let(:base_types) do 21 | %i[ 22 | int integer str string sym null true_class false_class hash array big_decimal float 23 | complex rational date date_time time any 24 | ] 25 | end 26 | 27 | let(:composite_types) do 28 | { 29 | bool: %i[true_class false_class], 30 | numeric: %i[int float big_decimal], 31 | kernel_num: %i[int float big_decimal complex rational], 32 | chrono: %i[date date_time time] 33 | } 34 | end 35 | 36 | describe '.list_types' do 37 | it 'returns an array of all type symbols defined in TYPE_MAP' do 38 | expect(described_class.list_types).to match_array(base_types + composite_types.keys) 39 | end 40 | end 41 | 42 | describe 'TYPE_MAP constant' do 43 | it 'contains the expected keys' do 44 | expect(HatiConfig::TypeMap::TYPE_MAP.keys).to match_array(base_types + composite_types.keys) 45 | end 46 | 47 | it 'maps numeric composite type correctly' do 48 | expect(HatiConfig::TypeMap::TYPE_MAP[:numeric]).to eq(composite_types[:numeric]) 49 | end 50 | 51 | it 'maps kernel_num composite type correctly' do 52 | expect(HatiConfig::TypeMap::TYPE_MAP[:kernel_num]).to eq(composite_types[:kernel_num]) 53 | end 54 | 55 | it 'maps chrono composite type correctly' do 56 | expect(HatiConfig::TypeMap::TYPE_MAP[:chrono]).to eq(composite_types[:chrono]) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/hati_config/type_map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bigdecimal' 4 | require 'date' 5 | 6 | # HatiConfig module provides functionality for managing HatiConfig features. 7 | module HatiConfig 8 | # A class that maps type symbols to their corresponding Ruby classes and provides 9 | # predefined collections of type symbols for various categories. 10 | # 11 | # This class allows retrieval of Ruby classes based on type symbols and provides 12 | # methods to access collections of specific type symbols such as booleans, numerics, 13 | # and chronological types. 14 | class TypeMap 15 | TYPE_MAP = { 16 | # base types 17 | int: Integer, 18 | integer: Integer, 19 | str: String, 20 | string: String, 21 | sym: Symbol, 22 | null: NilClass, 23 | true_class: TrueClass, 24 | false_class: FalseClass, 25 | # data structures 26 | hash: Hash, 27 | array: Array, 28 | # numeric types 29 | big_decimal: BigDecimal, 30 | float: Float, 31 | complex: Complex, 32 | rational: Rational, 33 | # time types 34 | date: Date, 35 | date_time: DateTime, 36 | time: Time, 37 | # any type 38 | any: Object, 39 | # composite types 40 | bool: %i[true_class false_class], 41 | numeric: %i[int float big_decimal], 42 | kernel_num: %i[int float big_decimal complex rational], 43 | chrono: %i[date date_time time] 44 | }.freeze 45 | 46 | # Retrieves the Ruby class associated with a given type symbol. 47 | # 48 | # @param type [Symbol] The type symbol to look up. Must be one of the keys in TYPE_MAP. 49 | # @return [Class, nil] The corresponding Ruby class or nil if the type symbol is not found. 50 | # 51 | # @example 52 | # TypeMap.get(:int) # => Integer 53 | # TypeMap.get(:str) # => String 54 | # TypeMap.get(:unknown) # => nil 55 | def self.get(type) 56 | TYPE_MAP[type] 57 | end 58 | 59 | # Returns an array of all type symbols defined in TYPE_MAP. 60 | # 61 | # @return [Array] An array containing all keys from TYPE_MAP. 62 | # 63 | # @example 64 | # TypeMap.list_types # => [:int, :str, :sym, :null, :true_class, :false_class, 65 | # :hash, :array, :big_decimal, :float, :complex, 66 | # :rational, :date, :date_time, :time, :any, 67 | # :bool, :numeric, :kernel_num, :chrono] 68 | def self.list_types 69 | TYPE_MAP.keys 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/hati_config/hati_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # HatiConfig module provides functionality for managing configuration features. 4 | # 5 | # @example Using HatiConfiguration in a class 6 | # class MyApp 7 | # include HatiConfiguration 8 | # 9 | # configure :settings do 10 | # config api_key: "default_key" 11 | # config max_retries: 3 12 | # end 13 | # end 14 | # 15 | # MyApp.settings.api_key # => "default_key" 16 | # MyApp.settings.max_retries # => 3 17 | # 18 | # @example Configuring with a block 19 | # class AnotherApp 20 | # include HatiConfiguration 21 | # 22 | # configure :app_settings do 23 | # config feature_enabled: true 24 | # config max_users: 100 25 | # end 26 | # end 27 | # 28 | # AnotherApp.app_settings.feature_enabled # => true 29 | # AnotherApp.app_settings.max_users # => 100 30 | module HatiConfiguration 31 | # Extends the base class with HatiConfig::Configuration and HatiConfig::Team module methods 32 | # 33 | # @param base [Class] The class extending this module 34 | # @return [void] 35 | def self.extended(base) 36 | base.extend(HatiConfig::Configuration) 37 | base.extend(HatiConfig::Team) 38 | end 39 | 40 | # Isolated module handles isolated configurations. 41 | # 42 | # This module allows for configuration inheritance while maintaining 43 | # isolation between parent and child configurations. 44 | # 45 | # @example Using Isolated configurations 46 | # class ParentApp 47 | # include HatiConfiguration::Isolated 48 | # 49 | # configure :parent_settings do 50 | # config timeout: 30 51 | # config tries: 3 52 | # end 53 | # end 54 | # 55 | # class ChildApp < ParentApp 56 | # parent_settings do 57 | # config tries: 4 58 | # end 59 | # end 60 | # 61 | # ChildApp.parent_settings.timeout # => 30 62 | # ChildApp.parent_settings.tries # => 4 63 | # ParentApp.parent_settings.tries # => 3 64 | # 65 | # @example Configuring a child class 66 | # class AnotherChildApp < ParentApp 67 | # parent_settings do 68 | # config timeout: 60 69 | # end 70 | # end 71 | # 72 | # AnotherChildApp.parent_settings.timeout # => 60 73 | # AnotherChildApp.parent_settings.tries # => 3 74 | module Local 75 | # Extends the base class with HatiConfig::Configuration and HatiConfig::Configuration::Isolated module methods 76 | # 77 | # @param base [Class] The class extending this module 78 | # @return [void] 79 | def self.extended(base) 80 | base.extend(HatiConfig::Configuration) 81 | base.extend(HatiConfig::Configuration::Local) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/hati_config/team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiConfig 4 | # Team module provides functionality for managing team-specific configurations. 5 | module Team 6 | # Defines team-specific configuration namespace. 7 | # 8 | # @param team_name [Symbol] The name of the team (e.g., :frontend, :backend, :mobile) 9 | # @yield The configuration block for the team 10 | # @example 11 | # team :frontend do 12 | # configure :settings do 13 | # config api_endpoint: '/api/v1' 14 | # config cache_ttl: 300 15 | # end 16 | # end 17 | def team(team_name, &block) 18 | team_module = Module.new do 19 | extend HatiConfig::Configuration 20 | extend HatiConfig::Environment 21 | end 22 | 23 | const_name = team_name.to_s.capitalize 24 | const_set(const_name, team_module) 25 | team_module.instance_eval(&block) if block_given? 26 | 27 | # Define method for accessing team module 28 | singleton_class.class_eval do 29 | define_method(team_name) { const_get(const_name) } 30 | end 31 | 32 | team_module 33 | end 34 | 35 | # Gets a list of all defined teams. 36 | # 37 | # @return [Array] The list of team names 38 | def teams 39 | constants.select { |c| const_get(c).is_a?(Module) && const_get(c).respond_to?(:configure) } 40 | end 41 | 42 | # Gets a specific team's configuration module. 43 | # 44 | # @param team_name [Symbol] The name of the team 45 | # @return [Module] The team's configuration module 46 | # @raise [NameError] If the team does not exist 47 | def [](team_name) 48 | const_get(team_name.to_s.capitalize) 49 | end 50 | 51 | # Checks if a team exists. 52 | # 53 | # @param team_name [Symbol] The name of the team 54 | # @return [Boolean] True if the team exists 55 | def team?(team_name) 56 | const_defined?(team_name.to_s.capitalize) 57 | end 58 | 59 | # Removes a team's configuration. 60 | # 61 | # @param team_name [Symbol] The name of the team 62 | # @return [Boolean] True if the team was removed 63 | def remove_team?(team_name) 64 | const_name = team_name.to_s.capitalize 65 | return false unless const_defined?(const_name) 66 | 67 | remove_const(const_name) 68 | true 69 | end 70 | 71 | # Temporarily switches to a team's configuration context. 72 | # 73 | # @param team_name [Symbol] The name of the team 74 | # @yield The block to execute in the team's context 75 | # @example 76 | # with_team(:frontend) do 77 | # # Configuration will use frontend team's context here 78 | # end 79 | def with_team(team_name) 80 | raise NameError, "Team '#{team_name}' does not exist" unless team?(team_name) 81 | 82 | yield self[team_name] 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/unit/hati_config/type_checker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::TypeChecker do 6 | let(:cstm_stub) { Class.new } 7 | 8 | describe '.call' do 9 | it 'validates if value matches the type' do 10 | expect(described_class.call(1, type: [:str, Integer])).to be(true) 11 | end 12 | 13 | it 'raises an error for nonexistent types' do 14 | expect { described_class.call(1, type: :unsupported) }.to raise_error(HatiConfig::TypeCheckerError) 15 | end 16 | end 17 | 18 | describe '.base_type' do 19 | it 'validates Integer type' do 20 | expect(described_class.base_type(1, Integer)).to be(true) 21 | end 22 | 23 | it 'validates built-in types' do 24 | aggregate_failures 'with one of valid types' do 25 | ['1', 1].each { |value| expect(described_class.base_type(value, [Integer, String])).to be(true) } 26 | end 27 | end 28 | 29 | it 'validates array of types' do 30 | expect(described_class.base_type(1, [Integer, :str, cstm_stub])).to be(true) 31 | end 32 | 33 | it 'returns false for invalid types' do 34 | expect(described_class.base_type('string', Integer)).to be(false) 35 | end 36 | 37 | it 'raises an error for nonexistent types' do 38 | expect { described_class.base_type(1, :nonexistent) }.to raise_error(HatiConfig::TypeCheckerError) 39 | end 40 | 41 | it 'handles nil values' do 42 | expect(described_class.base_type(nil, Integer)).to be(false) 43 | end 44 | 45 | it 'handles custom class types' do 46 | custom_instance = cstm_stub.new 47 | aggregate_failures 'validating custom class types' do 48 | expect(described_class.base_type(custom_instance, cstm_stub)).to be(true) 49 | expect(described_class.base_type(custom_instance, String)).to be(false) 50 | end 51 | end 52 | end 53 | 54 | describe '.custom_type?' do 55 | it 'validates if value matches the custom type' do 56 | expect(described_class.custom_type?(cstm_stub.new, cstm_stub)).to be(true) 57 | end 58 | 59 | it 'validates if value does not match the custom type' do 60 | expect(described_class.custom_type?(1, cstm_stub)).to be(false) 61 | end 62 | end 63 | 64 | describe '.one_of' do 65 | it 'validates if value matches any type in the array' do 66 | expect(described_class.one_of(1, [Integer, :str, cstm_stub])).to be(true) 67 | end 68 | 69 | it 'validates if value does not match any type in the array' do 70 | expect(described_class.one_of('string', [Integer, :int, cstm_stub])).to be(false) 71 | end 72 | end 73 | 74 | describe '.fetch_type' do 75 | it 'fetches the corresponding type if it exists' do 76 | expect(described_class.fetch_type(:int)).to eq(Integer) 77 | end 78 | 79 | it 'raises an error for nonexistent types' do 80 | expect { described_class.fetch_type(:nonexistent) }.to raise_error(HatiConfig::TypeCheckerError) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/hati_config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiConfig 4 | # Environment module provides functionality for managing environment-specific configurations. 5 | module Environment 6 | # Defines environment-specific configuration overrides. 7 | # 8 | # @param env_name [Symbol] The name of the environment (e.g., :development, :staging, :production) 9 | # @yield The configuration block for the environment 10 | # @example 11 | # environment :development do 12 | # config api_url: 'http://localhost:3000' 13 | # config debug: true 14 | # end 15 | def environment(env_name, &block) 16 | return unless current_environment == env_name 17 | 18 | instance_eval(&block) 19 | end 20 | 21 | # Sets the current environment. 22 | # 23 | # @param env [Symbol] The environment to set 24 | # @example 25 | # HatiConfig.environment = :production 26 | def self.current_environment=(env) 27 | @current_environment = env&.to_sym 28 | end 29 | 30 | # Gets the current environment. 31 | # 32 | # @return [Symbol] The current environment 33 | def self.current_environment 34 | @current_environment ||= begin 35 | env = if ENV['HATI_ENV'] 36 | ENV['HATI_ENV'] 37 | elsif ENV['RACK_ENV'] 38 | ENV['RACK_ENV'] 39 | elsif ENV['RAILS_ENV'] 40 | ENV['RAILS_ENV'] 41 | else 42 | 'development' 43 | end 44 | env.to_sym 45 | end 46 | end 47 | 48 | # Gets the current environment. 49 | # 50 | # @return [Symbol] The current environment 51 | def current_environment 52 | Environment.current_environment 53 | end 54 | 55 | # Temporarily changes the environment for a block of code. 56 | # 57 | # @param env [Symbol] The environment to use 58 | # @yield The block to execute in the specified environment 59 | # @example 60 | # HatiConfig.with_environment(:staging) do 61 | # # Configuration will use staging environment here 62 | # end 63 | def self.with_environment(env) 64 | original_env = current_environment 65 | self.current_environment = env 66 | yield 67 | ensure 68 | self.current_environment = original_env 69 | end 70 | 71 | # Checks if the current environment matches the given environment. 72 | # 73 | # @param env [Symbol] The environment to check 74 | # @return [Boolean] True if the current environment matches 75 | def environment?(env) 76 | current_environment == env.to_sym 77 | end 78 | 79 | # Checks if the current environment is development. 80 | # 81 | # @return [Boolean] True if in development environment 82 | def development? 83 | environment?(:development) 84 | end 85 | 86 | # Checks if the current environment is test. 87 | # 88 | # @return [Boolean] True if in test environment 89 | def test? 90 | environment?(:test) 91 | end 92 | 93 | # Checks if the current environment is staging. 94 | # 95 | # @return [Boolean] True if in staging environment 96 | def staging? 97 | environment?(:staging) 98 | end 99 | 100 | # Checks if the current environment is production. 101 | # 102 | # @return [Boolean] True if in production environment 103 | def production? 104 | environment?(:production) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/unit/hati_config/setting_encryption_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Setting do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Setting 9 | end 10 | end 11 | 12 | let(:settings) { described_class.new } 13 | 14 | before do 15 | ENV['HATI_CONFIG_ENCRYPTION_KEY'] = '0' * 32 # 256-bit key 16 | settings.class.encryption do 17 | key_provider :env 18 | end 19 | end 20 | 21 | after do 22 | ENV.delete('HATI_CONFIG_ENCRYPTION_KEY') 23 | end 24 | 25 | describe 'encrypted values' do 26 | it 'encrypts and decrypts values' do 27 | settings.config(:password, value: 'secret123', encrypted: true) 28 | encrypted_value = settings.instance_variable_get(:@config_tree)[:password] 29 | expect(encrypted_value).not_to eq('secret123') 30 | expect(settings[:password]).to eq('secret123') 31 | end 32 | 33 | it 'raises error for non-string encrypted values' do 34 | expect do 35 | settings.config(:number, value: 123, encrypted: true) 36 | end.to raise_error(HatiConfig::SettingTypeError, /must be strings/) 37 | end 38 | 39 | it 'handles encrypted values in nested settings' do 40 | settings.configure(:database) do 41 | config(:password, value: 'secret123', encrypted: true) 42 | end 43 | 44 | encrypted_value = settings.database.instance_variable_get(:@config_tree)[:password] 45 | expect(encrypted_value).not_to eq('secret123') 46 | expect(settings.database[:password]).to eq('secret123') 47 | end 48 | 49 | it 'preserves encryption when converting to hash' do 50 | settings.config(:password, value: 'secret123', encrypted: true) 51 | hash = settings.to_h 52 | expect(hash[:password]).to eq('secret123') 53 | end 54 | 55 | it 'preserves encryption in nested hashes' do 56 | settings.configure(:database) do 57 | config(:password, value: 'secret123', encrypted: true) 58 | config(:host, value: 'localhost') 59 | end 60 | 61 | hash = settings.to_h 62 | expect(hash[:database][:password]).to eq('secret123') 63 | expect(hash[:database][:host]).to eq('localhost') 64 | end 65 | 66 | it 'supports hash-like access for encrypted values' do 67 | settings[:password] = 'secret123' 68 | settings.config(:password, encrypted: true) 69 | expect(settings[:password]).to eq('secret123') 70 | end 71 | 72 | it 'supports multiple encrypted values' do 73 | settings.config(:password, value: 'secret123', encrypted: true) 74 | settings.config(:api_key, value: 'abc123', encrypted: true) 75 | settings.config(:host, value: 'localhost') 76 | 77 | expect(settings[:password]).to eq('secret123') 78 | expect(settings[:api_key]).to eq('abc123') 79 | expect(settings[:host]).to eq('localhost') 80 | end 81 | 82 | it 'preserves encryption when loading from hash' do 83 | settings.load_from_hash( 84 | { 85 | database: { 86 | password: 'secret123', 87 | host: 'localhost' 88 | } 89 | }, 90 | schema: { 91 | database: { 92 | password: :string, 93 | host: :string 94 | } 95 | } 96 | ) 97 | 98 | settings.configure(:database) do 99 | config(:password, encrypted: true) 100 | end 101 | 102 | expect(settings.database[:password]).to eq('secret123') 103 | expect(settings.database[:host]).to eq('localhost') 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/hati_config/remote_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'aws-sdk-s3' 5 | require 'redis' 6 | require 'json' 7 | require 'yaml' 8 | 9 | module HatiConfig 10 | # RemoteLoader handles loading configurations from remote sources like HTTP, S3, and Redis. 11 | # It supports automatic refresh and caching of configurations. 12 | class RemoteLoader 13 | class << self 14 | # Loads configuration from an HTTP endpoint 15 | # 16 | # @param url [String] The URL to load the configuration from 17 | # @param headers [Hash] Optional headers to include in the request 18 | # @param refresh_interval [Integer] Optional interval in seconds to refresh the configuration 19 | # @return [Hash] The loaded configuration 20 | # @raise [LoadDataError] If the configuration cannot be loaded 21 | def from_http(url:, headers: {}) 22 | uri = URI(url) 23 | request = Net::HTTP::Get.new(uri) 24 | headers.each { |key, value| request[key] = value } 25 | 26 | response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| 27 | http.request(request) 28 | end 29 | 30 | parse_response(response.body, File.extname(uri.path)) 31 | rescue StandardError => e 32 | raise LoadDataError, "Failed to load configuration from HTTP: #{e.message}" 33 | end 34 | 35 | # Loads configuration from an S3 bucket 36 | # 37 | # @param bucket [String] The S3 bucket name 38 | # @param key [String] The S3 object key 39 | # @param region [String] The AWS region 40 | # @param refresh_interval [Integer] Optional interval in seconds to refresh the configuration 41 | # @return [Hash] The loaded configuration 42 | # @raise [LoadDataError] If the configuration cannot be loaded 43 | def from_s3(bucket:, key:, region:) 44 | s3 = Aws::S3::Client.new(region: region) 45 | response = s3.get_object(bucket: bucket, key: key) 46 | parse_response(response.body.read, File.extname(key)) 47 | rescue Aws::S3::Errors::ServiceError => e 48 | raise LoadDataError, "Failed to load configuration from S3: #{e.message}" 49 | end 50 | 51 | # Loads configuration from Redis 52 | # 53 | # @param host [String] The Redis host 54 | # @param key [String] The Redis key 55 | # @param port [Integer] The Redis port (default: 6379) 56 | # @param db [Integer] The Redis database number (default: 0) 57 | # @param refresh_interval [Integer] Optional interval in seconds to refresh the configuration 58 | # @return [Hash] The loaded configuration 59 | # @raise [LoadDataError] If the configuration cannot be loaded 60 | def from_redis(host:, key:, port: 6379, db: 0) 61 | redis = Redis.new(host: host, port: port, db: db) 62 | data = redis.get(key) 63 | raise LoadDataError, "Key '#{key}' not found in Redis" unless data 64 | 65 | parse_response(data) 66 | rescue Redis::BaseError => e 67 | raise LoadDataError, "Failed to load configuration from Redis: #{e.message}" 68 | end 69 | 70 | private 71 | 72 | def parse_response(data, extension = nil) 73 | case extension&.downcase 74 | when '.json', nil 75 | JSON.parse(data, symbolize_names: true) 76 | when '.yaml', '.yml' 77 | YAML.safe_load(data, symbolize_names: true) 78 | else 79 | raise LoadDataError, "Unsupported file format: #{extension}" 80 | end 81 | rescue JSON::ParserError, Psych::SyntaxError => e 82 | raise LoadDataError, "Failed to parse configuration: #{e.message}" 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/integration/hati_config/class_level/local_configs_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'ClassLevel::Local::Configs' do 6 | before do 7 | support_dummy_class('BaseConfig') 8 | 9 | BaseConfig.configure :app do 10 | config param_a: 1, type: :int 11 | config.str param_b: '2' 12 | 13 | configure :nested do 14 | config.int param_a: 1 15 | config.str param_b: '2' 16 | 17 | configure :deep_nested do 18 | config param_a: 1 19 | config param_b: '2', type: :str 20 | end 21 | end 22 | end 23 | 24 | stub_const('InheritedConfig', Class.new(BaseConfig)) 25 | end 26 | 27 | context 'when inherit configirations' do 28 | it 'inherit configurations' do 29 | aggregate_failures do 30 | expect(BaseConfig.app.param_a).to eq(1) 31 | expect(InheritedConfig.app.param_b).to eq('2') 32 | end 33 | end 34 | 35 | it 'modify child configuration without modify parent' do 36 | InheritedConfig.app { param_b 'two' } 37 | aggregate_failures do 38 | expect(BaseConfig.app.param_a).to eq(1) 39 | expect(InheritedConfig.app.param_b).to eq('two') 40 | end 41 | end 42 | 43 | it 'expect error when setting param_a to non-integer value' do 44 | InheritedConfig.app { param_a 'switched_type', type: :str } 45 | 46 | aggregate_failures do 47 | expect { InheritedConfig.app { param_a 1 } }.to raise_error(HatiConfig::SettingTypeError) 48 | expect { InheritedConfig.app { param_b 1 } }.to raise_error(HatiConfig::SettingTypeError) 49 | end 50 | end 51 | end 52 | 53 | context 'when inherit nested configurations' do 54 | it 'inherit nested configurations' do 55 | aggregate_failures do 56 | expect(BaseConfig.app.nested.param_a).to eq(1) 57 | expect(InheritedConfig.app.nested.param_b).to eq('2') 58 | end 59 | end 60 | 61 | it 'modify deeply nested child configuration without modify parent' do 62 | InheritedConfig.app.nested { param_b 'nested_two' } 63 | 64 | aggregate_failures do 65 | expect(BaseConfig.app.nested.param_a).to eq(1) 66 | expect(InheritedConfig.app.nested.param_b).to eq('nested_two') 67 | end 68 | end 69 | 70 | it 'expect error when setting param_a to non-integer value' do 71 | InheritedConfig.app.nested { param_a 'switched_type', type: :str } 72 | 73 | aggregate_failures do 74 | expect { InheritedConfig.app.nested { param_a 1 } }.to raise_error(HatiConfig::SettingTypeError) 75 | expect { InheritedConfig.app.nested { param_b 1 } }.to raise_error(HatiConfig::SettingTypeError) 76 | end 77 | end 78 | end 79 | 80 | context 'when inherit deeply nested configurations' do 81 | it 'inherit deeply nested configurations' do 82 | aggregate_failures do 83 | expect(BaseConfig.app.nested.deep_nested.param_a).to eq(1) 84 | expect(InheritedConfig.app.nested.deep_nested.param_b).to eq('2') 85 | end 86 | end 87 | 88 | it 'modify deeply nested child configuration without modify parent' do 89 | InheritedConfig.app.nested.deep_nested { param_b 'deep_nested_two', type: :str } 90 | 91 | aggregate_failures do 92 | expect(BaseConfig.app.nested.deep_nested.param_a).to eq(1) 93 | expect(InheritedConfig.app.nested.deep_nested.param_b).to eq('deep_nested_two') 94 | end 95 | end 96 | 97 | it 'expect error when setting param_a to non-integer value' do 98 | InheritedConfig.app.nested.deep_nested { param_a 'switched_type', type: :str } 99 | 100 | aggregate_failures do 101 | expect { InheritedConfig.app.nested.deep_nested { param_a 1 } }.to raise_error(HatiConfig::SettingTypeError) 102 | expect { InheritedConfig.app.nested.deep_nested { param_b 1 } }.to raise_error(HatiConfig::SettingTypeError) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/unit/hati_config/team_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Team do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Team 9 | end 10 | end 11 | 12 | describe '#team' do 13 | it 'creates a new team configuration module' do 14 | team_module = dummy_class.team(:frontend) do 15 | configure :settings do 16 | config api_endpoint: '/api/v1' 17 | config cache_ttl: 300 18 | end 19 | end 20 | 21 | expect(team_module).to respond_to(:configure) 22 | expect(team_module.settings.api_endpoint).to eq('/api/v1') 23 | expect(team_module.settings.cache_ttl).to eq(300) 24 | end 25 | 26 | it 'supports environment-specific configuration' do 27 | HatiConfig::Environment.with_environment(:development) do 28 | dummy_class.team(:backend) do 29 | configure :settings do 30 | environment :development do 31 | config debug: true 32 | end 33 | 34 | environment :production do 35 | config debug: false 36 | end 37 | end 38 | end 39 | end 40 | 41 | expect(dummy_class.backend.settings.debug).to be true 42 | end 43 | 44 | it 'creates isolated configurations for each team' do 45 | dummy_class.team(:frontend) do 46 | configure :settings do 47 | config api_endpoint: '/api/v1' 48 | end 49 | end 50 | 51 | dummy_class.team(:backend) do 52 | configure :settings do 53 | config database_pool: 5 54 | end 55 | end 56 | 57 | expect(dummy_class.frontend.settings.api_endpoint).to eq('/api/v1') 58 | expect(dummy_class.backend.settings.database_pool).to eq(5) 59 | expect { dummy_class.frontend.settings.database_pool }.to raise_error(NoMethodError) 60 | expect { dummy_class.backend.settings.api_endpoint }.to raise_error(NoMethodError) 61 | end 62 | end 63 | 64 | describe '#teams' do 65 | before do 66 | dummy_class.team(:frontend) 67 | dummy_class.team(:backend) 68 | dummy_class.team(:mobile) 69 | end 70 | 71 | it 'returns a list of defined teams' do 72 | expect(dummy_class.teams).to match_array(%i[Frontend Backend Mobile]) 73 | end 74 | end 75 | 76 | describe '#[]' do 77 | before do 78 | dummy_class.team(:frontend) do 79 | configure :settings do 80 | config api_endpoint: '/api/v1' 81 | end 82 | end 83 | end 84 | 85 | it "returns the team's configuration module" do 86 | expect(dummy_class[:frontend].settings.api_endpoint).to eq('/api/v1') 87 | end 88 | 89 | it 'raises NameError for non-existent team' do 90 | expect { dummy_class[:unknown] }.to raise_error(NameError) 91 | end 92 | end 93 | 94 | describe '#team?' do 95 | before { dummy_class.team(:frontend) } 96 | 97 | it 'returns true for existing team' do 98 | expect(dummy_class.team?(:frontend)).to be true 99 | end 100 | 101 | it 'returns false for non-existent team' do 102 | expect(dummy_class.team?(:unknown)).to be false 103 | end 104 | end 105 | 106 | describe '#remove_team?' do 107 | before { dummy_class.team(:frontend) } 108 | 109 | it 'removes an existing team' do 110 | expect(dummy_class.remove_team?(:frontend)).to be true 111 | expect(dummy_class.team?(:frontend)).to be false 112 | end 113 | 114 | it 'returns false for non-existent team' do 115 | expect(dummy_class.remove_team?(:unknown)).to be false 116 | end 117 | end 118 | 119 | describe '#with_team' do 120 | before do 121 | dummy_class.team(:frontend) do 122 | configure :settings do 123 | config api_endpoint: '/api/v1' 124 | end 125 | end 126 | end 127 | 128 | it "executes block in team's context" do 129 | result = nil 130 | dummy_class.with_team(:frontend) do |team| 131 | result = team.settings.api_endpoint 132 | end 133 | expect(result).to eq('/api/v1') 134 | end 135 | 136 | it 'raises NameError for non-existent team' do 137 | expect { dummy_class.with_team(:unknown) { |_| nil } }.to raise_error(NameError) 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/hati_config/type_checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # HatiConfig module provides functionality for managing HatiConfig features. 4 | module HatiConfig 5 | # This class is responsible for type checking in the Hati configuration. 6 | class TypeChecker 7 | class << self 8 | # Calls the appropriate validation method based on the type. 9 | # 10 | # @param value [Object] The value to validate. 11 | # @param type [Symbol, Array, Class] The type(s) to validate against. 12 | # @return [Boolean] True if the value matches the type, false otherwise. 13 | # @raise [TypeCheckerError] if the type is unsupported or not defined. 14 | # 15 | # @example 16 | # TypeChecker.call(1, type: :int) # => true 17 | # TypeChecker.call(1, type: :numeric) # => true 18 | # TypeChecker.call("hello", type: [:str, Integer]) # => true 19 | # TypeChecker.call(CustomClass.new, type: CustomClass) # => true 20 | def call(value, type:, **_opts) 21 | case type 22 | when Symbol 23 | base_type(value, fetch_type(type)) 24 | when Array 25 | if type.length == 1 && type.first.is_a?(Symbol) 26 | # Array type validation (e.g., [:string] for array of strings) 27 | return false unless value.is_a?(Array) 28 | 29 | value.all? { |v| call(v, type: type.first) } 30 | else 31 | # Union type validation (e.g., [:string, Integer] for string or integer) 32 | one_of(value, type) 33 | end 34 | else 35 | custom_type?(value, type) 36 | end 37 | end 38 | 39 | # Validates if value matches the base type. 40 | # 41 | # @param value [Object] the value to validate 42 | # @param type [Class] the type to validate against 43 | # @return [Boolean] true if the value matches the base type 44 | # @raise [TypeCheckerError] if the type is unsupported or not defined. 45 | # 46 | # @example 47 | # TypeChecker.base_type(1, Integer) # => true 48 | # TypeChecker.base_type(1, [:int, :float, :big_decimal]) # => true 49 | def base_type(value, type) 50 | type = fetch_type(type) if type.is_a?(Symbol) 51 | 52 | return type.call(value) if type.is_a?(Proc) 53 | 54 | type.is_a?(Array) ? one_of(value, type) : value.is_a?(type) 55 | end 56 | 57 | # Checks if value matches any type in the array. 58 | # 59 | # @param value [Object] the value to validate 60 | # @param array_type [Array] the array of types to validate against 61 | # @return [Boolean] true if the value matches any type in the array 62 | # 63 | # @example 64 | # TypeChecker.one_of(1, [Integer, :str]) # => true 65 | # TypeChecker.one_of("hello", [Integer, String, :sym]) # => true 66 | # TypeChecker.one_of(nil, [:null, Integer, String]) # => true 67 | # TypeChecker.one_of(1.5, [:int, :float, :big_decimal]) # => true 68 | def one_of(value, array_type) 69 | array_type.any? { |type| type.is_a?(Symbol) ? base_type(value, type) : custom_type?(value, type) } 70 | end 71 | 72 | # Validates if value is of the specified custom type. 73 | # 74 | # @param value [Object] the value to validate 75 | # @param type [Class] the custom type to validate against 76 | # @return [Boolean] true if the value is of the specified custom type 77 | # 78 | # @example 79 | # TypeChecker.custom_type(1, Integer) # => true 80 | # TypeChecker.custom_type(CustomClass.new, CustomClass) # => true 81 | def custom_type?(value, type) 82 | value.is_a?(type) 83 | end 84 | 85 | # Fetches the basic type from the type map. 86 | # 87 | # @param type [Symbol] the type to fetch 88 | # @return [Class] the corresponding basic type 89 | # @raise [TypeCheckerError] if the type does not exist in TYPE_MAP 90 | # 91 | # @example 92 | # TypeChecker.fetch_type(:int) # => Integer 93 | # TypeChecker.fetch_type(:null) # => NilClass 94 | # TypeChecker.fetch_type(:bool) # => [:truthy, :falsy] 95 | def fetch_type(type) 96 | basic_type = TypeMap.get(type) 97 | raise HatiConfig::TypeCheckerError, type unless basic_type 98 | 99 | basic_type 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/unit/hati_config/remote_loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'webmock/rspec' 5 | 6 | RSpec.describe HatiConfig::RemoteLoader do 7 | describe '.from_http' do 8 | let(:config_url) { 'https://config-server.example.com/config.json' } 9 | let(:config_data) { { api_key: 'secret', timeout: 30 } } 10 | let(:headers) { { 'Authorization' => 'Bearer token' } } 11 | 12 | before do 13 | stub_request(:get, config_url) 14 | .with(headers: headers) 15 | .to_return(body: config_data.to_json, status: 200) 16 | end 17 | 18 | it 'loads configuration from HTTP endpoint' do 19 | config = described_class.from_http(url: config_url, headers: headers) 20 | expect(config).to eq(config_data) 21 | end 22 | 23 | context 'when the request fails' do 24 | before do 25 | stub_request(:get, config_url) 26 | .to_return(status: 404) 27 | end 28 | 29 | it 'raises LoadDataError' do 30 | expect do 31 | described_class.from_http(url: config_url) 32 | end.to raise_error(HatiConfig::LoadDataError) 33 | end 34 | end 35 | 36 | context 'with YAML format' do 37 | let(:yaml_url) { 'https://config-server.example.com/config.yml' } 38 | let(:yaml_data) { "api_key: secret\ntimeout: 30\n" } 39 | 40 | before do 41 | stub_request(:get, yaml_url) 42 | .to_return(body: yaml_data, status: 200) 43 | end 44 | 45 | it 'loads YAML configuration' do 46 | config = described_class.from_http(url: yaml_url) 47 | expect(config).to eq(config_data) 48 | end 49 | end 50 | end 51 | 52 | describe '.from_s3' do 53 | let(:s3_client) { instance_double(Aws::S3::Client) } 54 | let(:s3_response) { instance_double(Aws::S3::Types::GetObjectOutput) } 55 | let(:config_data) { { database_url: 'postgres://localhost', pool_size: 5 } } 56 | 57 | before do 58 | allow(Aws::S3::Client).to receive(:new).and_return(s3_client) 59 | allow(s3_response).to receive(:body).and_return(StringIO.new(config_data.to_json)) 60 | allow(s3_client).to receive(:get_object).and_return(s3_response) 61 | end 62 | 63 | it 'loads configuration from S3' do 64 | config = described_class.from_s3( 65 | bucket: 'my-configs', 66 | key: 'database.json', 67 | region: 'us-west-2' 68 | ) 69 | expect(config).to eq(config_data) 70 | end 71 | 72 | context 'when S3 request fails' do 73 | before do 74 | allow(s3_client).to receive(:get_object) 75 | .and_raise(Aws::S3::Errors::NoSuchKey.new(nil, 'Not found')) 76 | end 77 | 78 | it 'raises LoadDataError' do 79 | expect do 80 | described_class.from_s3( 81 | bucket: 'my-configs', 82 | key: 'database.json', 83 | region: 'us-west-2' 84 | ) 85 | end.to raise_error(HatiConfig::LoadDataError) 86 | end 87 | end 88 | end 89 | 90 | describe '.from_redis' do 91 | let(:redis_client) { instance_double(Redis) } 92 | let(:config_data) { { feature_flags: { dark_mode: true } } } 93 | 94 | before do 95 | allow(Redis).to receive(:new).and_return(redis_client) 96 | allow(redis_client).to receive(:get).and_return(config_data.to_json) 97 | end 98 | 99 | it 'loads configuration from Redis' do 100 | config = described_class.from_redis( 101 | host: 'localhost', 102 | key: 'feature_flags' 103 | ) 104 | expect(config).to eq(config_data) 105 | end 106 | 107 | context 'when key does not exist' do 108 | before do 109 | allow(redis_client).to receive(:get).and_return(nil) 110 | end 111 | 112 | it 'raises LoadDataError' do 113 | expect do 114 | described_class.from_redis( 115 | host: 'localhost', 116 | key: 'nonexistent' 117 | ) 118 | end.to raise_error(HatiConfig::LoadDataError) 119 | end 120 | end 121 | 122 | context 'when Redis connection fails' do 123 | before do 124 | allow(redis_client).to receive(:get) 125 | .and_raise(Redis::CannotConnectError) 126 | end 127 | 128 | it 'raises LoadDataError' do 129 | expect do 130 | described_class.from_redis( 131 | host: 'localhost', 132 | key: 'feature_flags' 133 | ) 134 | end.to raise_error(HatiConfig::LoadDataError) 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/unit/hati_config/setting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Setting do 6 | let(:setting) { described_class.new } 7 | 8 | describe '#initialize' do 9 | let(:app_settings) do 10 | described_class.new do 11 | config username: 'admin' 12 | config max_connections: 10, type: :int 13 | end 14 | end 15 | 16 | it 'initializes with a block and configures settings' do 17 | aggregate_failures do 18 | expect(app_settings.username).to eq('admin') 19 | expect(app_settings.max_connections).to eq(10) 20 | expect(app_settings.type_schema).to include(username: :any, max_connections: :int) 21 | end 22 | end 23 | 24 | it 'does not raise an error when no block is given' do 25 | expect { setting }.not_to raise_error 26 | end 27 | end 28 | 29 | describe '#configure' do 30 | before do 31 | setting.configure :new_node do 32 | config key: 'value' 33 | end 34 | end 35 | 36 | it 'configures a new node in the configuration tree' do 37 | aggregate_failures do 38 | expect(setting.new_node).to be_a(described_class) 39 | expect(setting.new_node.key).to eq('value') 40 | end 41 | end 42 | end 43 | 44 | describe '#config' do 45 | it 'raises an error for invalid type' do 46 | expect { setting.config(key: 'value', type: :int) }.to raise_error(HatiConfig::SettingTypeError) 47 | end 48 | 49 | it 'stores valid configuration' do 50 | setting.config(key: 'value') 51 | 52 | expect(setting.key).to eq('value') 53 | end 54 | end 55 | 56 | describe '#to_h' do 57 | it 'converts the configuration tree to a hash' do 58 | setting.config(key: 'value') 59 | 60 | expect(setting.to_h).to eq({ key: 'value' }) 61 | end 62 | end 63 | 64 | describe '#to_yaml' do 65 | it 'converts the configuration tree to YAML' do 66 | setting.config(key: 'value') 67 | 68 | expect(setting.to_yaml).to include('key: value') 69 | end 70 | end 71 | 72 | describe '#to_json' do 73 | it 'converts the configuration tree to JSON' do 74 | setting.config(key: 'value') 75 | 76 | expect(setting.to_json).to include('"key":"value"') 77 | end 78 | end 79 | 80 | describe '#type_schema' do 81 | it 'returns the type schema of the configuration' do 82 | setting.config(key1: 'value1', type: :str) 83 | setting.config(key2: 42, type: :int) 84 | expected_schema = { key1: :str, key2: :int } 85 | 86 | expect(setting.type_schema).to eq(expected_schema) 87 | end 88 | 89 | it 'returns an empty hash when no configurations are set' do 90 | expect(setting.type_schema).to eq({}) 91 | end 92 | 93 | it 'handles nested settings' do 94 | setting.configure :nested do 95 | config key3: 'value3', type: :str 96 | end 97 | 98 | expected_schema = { key3: :str } 99 | 100 | expect(setting.nested.type_schema).to eq(expected_schema) 101 | end 102 | end 103 | 104 | describe 'hash-like access' do 105 | before do 106 | setting.config(key1: 'value1') 107 | setting.config(key2: 42) 108 | setting.configure :nested do 109 | config key3: 'value3' 110 | end 111 | end 112 | 113 | describe '#[]' do 114 | it 'accesses configuration values using string keys' do 115 | expect(setting['key1']).to eq('value1') 116 | expect(setting['key2']).to eq(42) 117 | end 118 | 119 | it 'accesses configuration values using symbol keys' do 120 | expect(setting[:key1]).to eq('value1') 121 | expect(setting[:key2]).to eq(42) 122 | end 123 | 124 | it 'raises NoMethodError for non-existent keys' do 125 | expect { setting[:non_existent] }.to raise_error(NoMethodError) 126 | end 127 | 128 | it 'returns Setting instance for nested configurations' do 129 | expect(setting[:nested]).to be_a(described_class) 130 | expect(setting[:nested][:key3]).to eq('value3') 131 | end 132 | end 133 | 134 | describe '#[]=' do 135 | it 'sets configuration values using string keys' do 136 | setting['key4'] = 'new value' 137 | expect(setting.key4).to eq('new value') 138 | end 139 | 140 | it 'sets configuration values using symbol keys' do 141 | setting[:key5] = 'another value' 142 | expect(setting.key5).to eq('another value') 143 | end 144 | 145 | it 'updates existing configuration values' do 146 | setting[:key1] = 'updated value' 147 | expect(setting.key1).to eq('updated value') 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/unit/hati_config/schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Schema do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Schema 9 | end 10 | end 11 | 12 | describe '#schema' do 13 | it 'creates a schema definition with version' do 14 | schema = dummy_class.schema(version: '2.0') do 15 | required :database_url, type: :string 16 | optional :pool_size, type: :integer, default: 5 17 | end 18 | 19 | expect(schema.version).to eq('2.0') 20 | expect(schema.required_fields).to include(:database_url) 21 | expect(schema.optional_fields).to include(:pool_size) 22 | end 23 | 24 | it 'defaults to version 1.0' do 25 | schema = dummy_class.schema do 26 | required :api_key, type: :string 27 | end 28 | 29 | expect(schema.version).to eq('1.0') 30 | end 31 | end 32 | 33 | describe '#schema_version' do 34 | it 'returns the current schema version' do 35 | dummy_class.schema(version: '2.0') { |s| s.field :name, type: String } 36 | expect(dummy_class.schema_version).to eq('2.0') 37 | end 38 | 39 | it 'defaults to 1.0' do 40 | expect(dummy_class.schema_version).to eq('1.0') 41 | end 42 | end 43 | 44 | describe 'SchemaDefinition' do 45 | let(:schema) do 46 | dummy_class.schema(version: '2.0') do 47 | required :database_url, type: :string, since: '1.0' 48 | required :replica_urls, type: [:string], since: '2.0' 49 | optional :pool_size, type: :integer, default: 5 50 | deprecated :old_setting, since: '2.0', remove_in: '3.0' 51 | end 52 | end 53 | 54 | describe '#validate' do 55 | let(:valid_data) do 56 | { 57 | database_url: 'postgres://localhost', 58 | replica_urls: ['postgres://replica1', 'postgres://replica2'], 59 | pool_size: 10 60 | } 61 | end 62 | 63 | it 'validates valid data' do 64 | expect { schema.validate(valid_data) }.not_to raise_error 65 | end 66 | 67 | it 'validates required fields' do 68 | invalid_data = valid_data.dup 69 | invalid_data.delete(:database_url) 70 | 71 | expect { schema.validate(invalid_data) } 72 | .to raise_error(HatiConfig::Schema::ValidationError, /Missing required field/) 73 | end 74 | 75 | it 'validates field types' do 76 | invalid_data = valid_data.dup 77 | invalid_data[:pool_size] = 'not an integer' 78 | 79 | expect { schema.validate(invalid_data) } 80 | .to raise_error(HatiConfig::Schema::ValidationError, /Invalid type/) 81 | end 82 | 83 | it 'validates array types' do 84 | invalid_data = valid_data.dup 85 | invalid_data[:replica_urls] = 'not an array' 86 | 87 | expect { schema.validate(invalid_data) } 88 | .to raise_error(HatiConfig::Schema::ValidationError, /Invalid type/) 89 | end 90 | 91 | it 'handles version-specific required fields' do 92 | data_without_replica = valid_data.dup 93 | data_without_replica.delete(:replica_urls) 94 | 95 | expect { schema.validate(data_without_replica, '1.0') }.not_to raise_error 96 | expect { schema.validate(data_without_replica, '2.0') } 97 | .to raise_error(HatiConfig::Schema::ValidationError, /Missing required field/) 98 | end 99 | 100 | it 'warns about deprecated fields' do 101 | data_with_deprecated = valid_data.dup 102 | data_with_deprecated[:old_setting] = 'value' 103 | 104 | expect do 105 | schema.validate(data_with_deprecated) 106 | end.to output(/deprecated since version 2.0/).to_stderr 107 | end 108 | 109 | it 'raises error for removed fields' do 110 | data_with_removed = valid_data.dup 111 | data_with_removed[:old_setting] = 'value' 112 | 113 | expect { schema.validate(data_with_removed, '3.0') } 114 | .to raise_error(HatiConfig::Schema::ValidationError, /was removed in version/) 115 | end 116 | end 117 | 118 | describe '#migrate' do 119 | before do 120 | schema.add_migration('1.0', '2.0') do |config| 121 | config[:replica_urls] = [config.delete(:backup_url)].compact 122 | end 123 | end 124 | 125 | it 'migrates configuration data' do 126 | old_data = { 127 | database_url: 'postgres://localhost', 128 | backup_url: 'postgres://backup', 129 | pool_size: 5 130 | } 131 | 132 | new_data = schema.migrate(old_data, '1.0', '2.0') 133 | 134 | expect(new_data[:replica_urls]).to eq(['postgres://backup']) 135 | expect(new_data).not_to include(:backup_url) 136 | end 137 | 138 | it 'raises error for missing migration path' do 139 | expect { schema.migrate({}, '1.0', '3.0') } 140 | .to raise_error(HatiConfig::Schema::MigrationError, /No migration path/) 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/unit/hati_config/environment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Environment do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Environment 9 | 10 | def self.config_values 11 | @config_values ||= {} 12 | end 13 | 14 | def self.config(values) 15 | config_values.merge!(values) 16 | end 17 | end 18 | end 19 | 20 | describe '.current_environment=' do 21 | after { described_class.current_environment = nil } 22 | 23 | it 'sets the current environment' do 24 | described_class.current_environment = :production 25 | expect(described_class.current_environment).to eq(:production) 26 | end 27 | 28 | it 'converts string environment to symbol' do 29 | described_class.current_environment = 'staging' 30 | expect(described_class.current_environment).to eq(:staging) 31 | end 32 | end 33 | 34 | describe '.current_environment' do 35 | before do 36 | described_class.current_environment = nil 37 | ENV.delete('HATI_ENV') 38 | ENV.delete('RACK_ENV') 39 | ENV.delete('RAILS_ENV') 40 | end 41 | 42 | after do 43 | ENV.delete('HATI_ENV') 44 | ENV.delete('RACK_ENV') 45 | ENV.delete('RAILS_ENV') 46 | end 47 | 48 | it 'defaults to development' do 49 | expect(described_class.current_environment).to eq(:development) 50 | end 51 | 52 | it 'uses HATI_ENV environment variable' do 53 | ENV['HATI_ENV'] = 'production' 54 | expect(described_class.current_environment).to eq(:production) 55 | end 56 | 57 | it 'uses RACK_ENV environment variable' do 58 | ENV['RACK_ENV'] = 'staging' 59 | expect(described_class.current_environment).to eq(:staging) 60 | end 61 | 62 | it 'uses RAILS_ENV environment variable' do 63 | ENV['RAILS_ENV'] = 'test' 64 | expect(described_class.current_environment).to eq(:test) 65 | end 66 | 67 | it 'prioritizes HATI_ENV over other environment variables' do 68 | ENV['HATI_ENV'] = 'production' 69 | ENV['RACK_ENV'] = 'staging' 70 | ENV['RAILS_ENV'] = 'development' 71 | expect(described_class.current_environment).to eq(:production) 72 | end 73 | end 74 | 75 | describe '.with_environment' do 76 | it 'temporarily changes the environment' do 77 | original_env = described_class.current_environment 78 | result = described_class.with_environment(:test) do 79 | described_class.current_environment 80 | end 81 | expect(result).to eq(:test) 82 | expect(described_class.current_environment).to eq(original_env) 83 | end 84 | 85 | it 'restores the environment even if an error occurs' do 86 | original_env = described_class.current_environment 87 | begin 88 | described_class.with_environment(:test) { raise 'error' } 89 | rescue StandardError 90 | # Ignore error 91 | end 92 | expect(described_class.current_environment).to eq(original_env) 93 | end 94 | end 95 | 96 | describe '#environment' do 97 | before { described_class.current_environment = :development } 98 | after { described_class.current_environment = nil } 99 | 100 | it 'executes the block when environment matches' do 101 | dummy_class.environment(:development) do 102 | config(debug: true) 103 | end 104 | expect(dummy_class.config_values[:debug]).to be true 105 | end 106 | 107 | it 'does not execute the block when environment does not match' do 108 | dummy_class.environment(:production) do 109 | config(debug: true) 110 | end 111 | expect(dummy_class.config_values[:debug]).to be_nil 112 | end 113 | end 114 | 115 | describe 'environment checks' do 116 | after { described_class.current_environment = nil } 117 | 118 | context 'when in development' do 119 | before { described_class.current_environment = :development } 120 | 121 | it { expect(dummy_class.development?).to be true } 122 | it { expect(dummy_class.test?).to be false } 123 | it { expect(dummy_class.staging?).to be false } 124 | it { expect(dummy_class.production?).to be false } 125 | end 126 | 127 | context 'when in test' do 128 | before { described_class.current_environment = :test } 129 | 130 | it { expect(dummy_class.development?).to be false } 131 | it { expect(dummy_class.test?).to be true } 132 | it { expect(dummy_class.staging?).to be false } 133 | it { expect(dummy_class.production?).to be false } 134 | end 135 | 136 | context 'when in staging' do 137 | before { described_class.current_environment = :staging } 138 | 139 | it { expect(dummy_class.development?).to be false } 140 | it { expect(dummy_class.test?).to be false } 141 | it { expect(dummy_class.staging?).to be true } 142 | it { expect(dummy_class.production?).to be false } 143 | end 144 | 145 | context 'when in production' do 146 | before { described_class.current_environment = :production } 147 | 148 | it { expect(dummy_class.development?).to be false } 149 | it { expect(dummy_class.test?).to be false } 150 | it { expect(dummy_class.staging?).to be false } 151 | it { expect(dummy_class.production?).to be true } 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # [hati-rb] Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We, as members, contributors, and leaders of [Your Community Name], pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward others. 14 | - Being respectful of differing opinions, viewpoints, and experiences. 15 | - Giving and gracefully accepting constructive feedback. 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience. 17 | - Focusing on what is best not just for us as individuals, but for the overall community. 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind. 22 | - Trolling, insulting or derogatory comments, and personal or political attacks. 23 | - Public or private harassment. 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission. 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting. 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [CONTACT](https://github.com/yurigitsu). All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 79 | [FAQ]: https://www.contributor-covenant.org/faq 80 | [translations]: https://www.contributor-covenant.org/translations 81 | -------------------------------------------------------------------------------- /spec/integration/hati_config/class_level/flat_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'ClassLevel::Flat' do 6 | let(:stng_values) do 7 | { 8 | one: 1, 9 | text: 'Lorem Ipsum', 10 | float_point: 4.2, 11 | none: nil 12 | } 13 | end 14 | 15 | context 'when flat namespace' do 16 | let(:configs) do 17 | support_dummy_module.tap do |dummy_module| 18 | dummy_module.configure :settings do 19 | config :opt 20 | config :dsl_opt 21 | config.int :t_opt 22 | config :type_opt, type: :int 23 | config :cstm_type_opt, type: Integer 24 | end 25 | end 26 | end 27 | 28 | describe 'declared typed #config' do 29 | it 'has #opt config param' do 30 | configs.settings.config(opt: stng_values[:text]) 31 | expect(configs.settings.opt).to eq(stng_values[:text]) 32 | end 33 | 34 | it 'has #dsl_opt config param' do 35 | test_value = stng_values[:text] 36 | configs.settings do 37 | dsl_opt test_value 38 | end 39 | 40 | expect(configs.settings.dsl_opt).to eq(test_value) 41 | end 42 | 43 | it 'has #type_opt config param' do 44 | expect { configs.settings.config(t_opt: stng_values[:text]) }.to raise_error(HatiConfig::SettingTypeError) 45 | end 46 | 47 | it 'has #t_opt config param' do 48 | expect { configs.settings.config(type_opt: stng_values[:text]) }.to raise_error(HatiConfig::SettingTypeError) 49 | end 50 | 51 | it 'has #ctm_t_type_opt config param' do 52 | expect do 53 | configs.settings.config(cstm_type_opt: stng_values[:text]) 54 | end.to raise_error(HatiConfig::SettingTypeError) 55 | end 56 | end 57 | 58 | context 'when extracting configuration details' do 59 | before do 60 | configs.settings.config(opt: stng_values[:text]) 61 | configs.settings.config(dsl_opt: stng_values[:text]) 62 | configs.settings.config(t_opt: 10, type: :int) 63 | configs.settings.config(type_opt: 10) 64 | configs.settings.config(cstm_type_opt: 10) 65 | end 66 | 67 | it 'returns the type schema of the configuration' do 68 | expected_schema = { opt: :any, dsl_opt: :any, t_opt: :int, type_opt: :int, cstm_type_opt: Integer } 69 | expect(configs.settings.type_schema).to eq(expected_schema) 70 | end 71 | 72 | it 'converts the configuration tree to a hash' do 73 | expected_hash = { opt: stng_values[:text], dsl_opt: stng_values[:text], t_opt: 10, type_opt: 10, 74 | cstm_type_opt: 10 } 75 | expect(configs.settings.to_h).to eq(expected_hash) 76 | end 77 | 78 | it 'converts the configuration tree to JSON' do 79 | expected_json = { opt: stng_values[:text], dsl_opt: stng_values[:text], t_opt: 10, type_opt: 10, 80 | cstm_type_opt: 10 }.to_json 81 | expect(configs.settings.to_json).to eq(expected_json) 82 | end 83 | 84 | it 'converts the configuration tree to YAML' do 85 | expected_yaml = { opt: stng_values[:text], dsl_opt: stng_values[:text], t_opt: 10, type_opt: 10, 86 | cstm_type_opt: 10 }.to_yaml 87 | expect(configs.settings.to_yaml).to eq(expected_yaml) 88 | end 89 | end 90 | 91 | describe '#config' do 92 | let(:configs) do 93 | val = stng_values 94 | 95 | support_dummy_module.tap do |dummy_module| 96 | dummy_module.configure :settings do 97 | config one: val[:one] 98 | config.str text: val[:text] 99 | config float_point: val[:float_point], type: :float 100 | config none: val[:none], type: NilClass 101 | end 102 | end 103 | end 104 | 105 | it 'has #one config parameter' do 106 | expect(configs.settings.one).to eq(stng_values[:one]) 107 | end 108 | 109 | it 'has #text config parameter' do 110 | expect(configs.settings.text).to eq(stng_values[:text]) 111 | end 112 | 113 | it 'has #float_point config parameter' do 114 | expect(configs.settings.float_point).to eq(stng_values[:float_point]) 115 | end 116 | 117 | it 'has #none config parameter' do 118 | expect(configs.settings.none).to eq(stng_values[:none]) 119 | end 120 | 121 | it 'returns the type schema of the configuration' do 122 | expected_schema = { one: :any, text: :str, float_point: :float, none: NilClass } 123 | expect(configs.settings.type_schema).to eq(expected_schema) 124 | end 125 | 126 | it 'converts the configuration tree to a hash' do 127 | expected_hash = { one: stng_values[:one], text: stng_values[:text], float_point: stng_values[:float_point], 128 | none: stng_values[:none] } 129 | expect(configs.settings.to_h).to eq(expected_hash) 130 | end 131 | 132 | it 'converts the configuration tree to JSON' do 133 | expected_json = { one: stng_values[:one], text: stng_values[:text], float_point: stng_values[:float_point], 134 | none: stng_values[:none] }.to_json 135 | expect(configs.settings.to_json).to eq(expected_json) 136 | end 137 | 138 | it 'converts the configuration tree to YAML' do 139 | expected_yaml = { one: stng_values[:one], text: stng_values[:text], float_point: stng_values[:float_point], 140 | none: stng_values[:none] }.to_yaml 141 | expect(configs.settings.to_yaml).to eq(expected_yaml) 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /spec/integration/hati_config/class_level/deep_nested_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'ClassLevel::DeepNested' do 6 | let(:stng_values) do 7 | { 8 | one: 1, 9 | text: 'Lorem Ipsum', 10 | float_point: 4.2, 11 | none: nil 12 | } 13 | end 14 | 15 | context 'when using a deep nested namespace' do 16 | let(:configs) do 17 | support_dummy_module.tap do |dummy_module| 18 | dummy_module.configure :settings do 19 | configure :nested do 20 | configure :deep_nested do 21 | config :opt 22 | config.str :dsl_opt 23 | config.int :t_opt 24 | config :type_opt, type: :int 25 | config :cstm_type_opt, type: Integer 26 | end 27 | end 28 | end 29 | end 30 | end 31 | 32 | describe 'declared typed #config' do 33 | it 'has #opt config param' do 34 | configs.settings.config(opt: stng_values[:text]) 35 | expect(configs.settings.opt).to eq(stng_values[:text]) 36 | end 37 | 38 | it 'has #dsl_opt config param' do 39 | test_value = stng_values[:text] 40 | 41 | configs.settings.nested.deep_nested do 42 | config dsl_opt: test_value 43 | end 44 | 45 | expect(configs.settings.nested.deep_nested.dsl_opt).to eq(test_value) 46 | end 47 | 48 | it 'has #type_opt config param' do 49 | expect do 50 | configs.settings.nested.deep_nested.config(t_opt: stng_values[:text]) 51 | end.to raise_error(HatiConfig::SettingTypeError) 52 | end 53 | 54 | it 'has #t_opt config param' do 55 | expect do 56 | configs.settings.nested.deep_nested.config(type_opt: stng_values[:text]) 57 | end.to raise_error(HatiConfig::SettingTypeError) 58 | end 59 | 60 | it 'has #ctm_t_type_opt config param' do 61 | expect do 62 | configs.settings.nested.deep_nested.config(cstm_type_opt: stng_values[:text]) 63 | end.to raise_error(HatiConfig::SettingTypeError) 64 | end 65 | end 66 | 67 | describe 'when extracting deep nested settings' do 68 | before do 69 | val = stng_values 70 | 71 | configs.settings.nested.deep_nested.config(opt: val[:text]) 72 | configs.settings.nested.deep_nested do 73 | dsl_opt val[:text] 74 | end 75 | configs.settings.nested.deep_nested.config(t_opt: 10, type: :int) 76 | configs.settings.nested.deep_nested.config(type_opt: 10) 77 | configs.settings.nested.deep_nested.config(cstm_type_opt: 10) 78 | end 79 | 80 | it 'returns the type schema of the configuration' do 81 | expected_schema = { opt: :any, dsl_opt: :str, t_opt: :int, type_opt: :int, cstm_type_opt: Integer } 82 | 83 | expect(configs.settings.nested.deep_nested.type_schema).to eq(expected_schema) 84 | end 85 | 86 | it 'converts the configuration tree to a hash' do 87 | expected_hash = { opt: stng_values[:text], dsl_opt: stng_values[:text], t_opt: 10, type_opt: 10, 88 | cstm_type_opt: 10 } 89 | 90 | expect(configs.settings.nested.deep_nested.to_h).to eq(expected_hash) 91 | end 92 | 93 | it 'converts the configuration tree to JSON' do 94 | expected_json = { opt: stng_values[:text], dsl_opt: stng_values[:text], t_opt: 10, type_opt: 10, 95 | cstm_type_opt: 10 }.to_json 96 | 97 | expect(configs.settings.nested.deep_nested.to_json).to eq(expected_json) 98 | end 99 | end 100 | 101 | describe '#config' do 102 | let(:configs) do 103 | val = stng_values 104 | support_dummy_module.tap do |dummy_module| 105 | dummy_module.configure :settings do 106 | configure :nested do 107 | configure :deep_nested do 108 | config one: val[:one] 109 | config.str text: val[:text] 110 | config float_point: val[:float_point], type: :float 111 | config none: val[:none], type: NilClass 112 | end 113 | end 114 | end 115 | end 116 | end 117 | 118 | it 'has #one config parameter' do 119 | expect(configs.settings.nested.deep_nested.one).to eq(stng_values[:one]) 120 | end 121 | 122 | it 'has #text config parameter' do 123 | expect(configs.settings.nested.deep_nested.text).to eq(stng_values[:text]) 124 | end 125 | 126 | it 'has #float_point config parameter' do 127 | expect(configs.settings.nested.deep_nested.float_point).to eq(stng_values[:float_point]) 128 | end 129 | 130 | it 'has #none config parameter' do 131 | expect(configs.settings.nested.deep_nested.none).to eq(stng_values[:none]) 132 | end 133 | 134 | it 'returns the type schema of the configuration' do 135 | expected_schema = { nested: { deep_nested: { float_point: :float, none: NilClass, one: :any, text: :str } } } 136 | expect(configs.settings.type_schema).to eq(expected_schema) 137 | end 138 | 139 | it 'converts the configuration tree to a hash' do 140 | expected_hash = { nested: { deep_nested: { float_point: 4.2, none: nil, one: 1, text: 'Lorem Ipsum' } } } 141 | expect(configs.settings.to_h).to eq(expected_hash) 142 | end 143 | 144 | it 'converts the configuration tree to JSON' do 145 | expected_json = { nested: { deep_nested: { one: 1, text: 'Lorem Ipsum', float_point: 4.2, 146 | none: nil } } }.to_json 147 | expect(configs.settings.to_json).to eq(expected_json) 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/integration/hati_config/class_level/nested_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'ClassLevel::Nested' do 6 | let(:stng_values) do 7 | { 8 | one: 1, 9 | text: 'Lorem Ipsum', 10 | float_point: 4.2, 11 | none: nil 12 | } 13 | end 14 | 15 | context 'when nested namespace' do 16 | let(:configs) do 17 | support_dummy_module.tap do |dummy_module| 18 | dummy_module.configure :settings do 19 | configure :nested do 20 | config :opt 21 | config.str :dsl_opt 22 | config.int :t_opt 23 | config :type_opt, type: :int 24 | config :cstm_type_opt, type: Integer 25 | end 26 | end 27 | end 28 | end 29 | 30 | describe 'declared typed #config' do 31 | it 'has #opt config param' do 32 | configs.settings.config opt: stng_values[:text] 33 | expect(configs.settings.opt).to eq(stng_values[:text]) 34 | end 35 | 36 | it 'has #dsl_opt config param' do 37 | test_value = stng_values[:text] 38 | 39 | configs.settings.nested do 40 | config dsl_opt: test_value 41 | end 42 | 43 | expect(configs.settings.nested.dsl_opt).to eq(test_value) 44 | end 45 | 46 | it 'has #type_opt config param' do 47 | expect { configs.settings.nested.config(t_opt: stng_values[:text]) }.to raise_error(HatiConfig::SettingTypeError) 48 | end 49 | 50 | it 'has #t_opt config param' do 51 | expect do 52 | configs.settings.nested.config(type_opt: stng_values[:text]) 53 | end.to raise_error(HatiConfig::SettingTypeError) 54 | end 55 | 56 | it 'has #cstm_type_opt config param' do 57 | expect do 58 | configs.settings.nested.config cstm_type_opt: stng_values[:text] 59 | end.to raise_error(HatiConfig::SettingTypeError) 60 | end 61 | end 62 | 63 | context 'when retrieving configuration details' do 64 | before do 65 | val = stng_values 66 | 67 | configs.tap do |dummy_module| 68 | dummy_module.settings.nested do 69 | config opt: val[:text] 70 | dsl_opt val[:text] 71 | config t_opt: val[:int] 72 | config type_opt: val[:int] 73 | config cstm_type_opt: val[:int] 74 | end 75 | end 76 | end 77 | 78 | let(:expected_structure) do 79 | { 80 | nested: { 81 | opt: stng_values[:text], 82 | dsl_opt: stng_values[:text], 83 | t_opt: stng_values[:int], 84 | type_opt: stng_values[:int], 85 | cstm_type_opt: stng_values[:int] 86 | } 87 | } 88 | end 89 | 90 | it 'returns the type schema of the configuration' do 91 | expected_schema = { nested: { opt: :any, dsl_opt: :str, t_opt: :int, type_opt: :int, cstm_type_opt: Integer } } 92 | 93 | expect(configs.settings.type_schema).to eq(expected_schema) 94 | end 95 | 96 | it 'converts the configuration tree to a hash' do 97 | expect(configs.settings.to_h).to eq(expected_structure) 98 | end 99 | 100 | it 'converts the configuration tree to JSON' do 101 | expect(configs.settings.to_json).to eq(expected_structure.to_json) 102 | end 103 | 104 | it 'converts the configuration tree to YAML' do 105 | expect(configs.settings.to_yaml).to eq(expected_structure.to_yaml) 106 | end 107 | end 108 | 109 | describe '#config' do 110 | let(:configs) do 111 | val = stng_values 112 | 113 | support_dummy_module.tap do |dummy_module| 114 | dummy_module.configure :settings do 115 | configure :nested do 116 | config one: val[:one] 117 | config.str text: val[:text] 118 | config float_point: val[:float_point], type: :float 119 | config none: val[:none], type: NilClass 120 | end 121 | end 122 | end 123 | end 124 | 125 | let(:expected_structure) do 126 | { 127 | nested: { 128 | one: stng_values[:one], 129 | text: stng_values[:text], 130 | float_point: stng_values[:float_point], 131 | none: stng_values[:none] 132 | } 133 | } 134 | end 135 | 136 | it 'has #one configuration parameter' do 137 | expect(configs.settings.nested.one).to eq(stng_values[:one]) 138 | end 139 | 140 | it 'has #text configuration parameter' do 141 | expect(configs.settings.nested.text).to eq(stng_values[:text]) 142 | end 143 | 144 | it 'has #float_point configuration parameter' do 145 | expect(configs.settings.nested.float_point).to eq(stng_values[:float_point]) 146 | end 147 | 148 | it 'has #none configuration parameter' do 149 | expect(configs.settings.nested.none).to eq(stng_values[:none]) 150 | end 151 | 152 | it 'returns the type schema of the configuration' do 153 | expected_schema = { nested: { one: :any, text: :str, float_point: :float, none: NilClass } } 154 | expect(configs.settings.type_schema).to eq(expected_schema) 155 | end 156 | 157 | it 'converts the configuration tree to a hash' do 158 | expect(configs.settings.to_h).to eq(expected_structure) 159 | end 160 | 161 | it 'converts the configuration tree to JSON' do 162 | expect(configs.settings.to_json).to eq(expected_structure.to_json) 163 | end 164 | 165 | it 'converts the configuration tree to YAML' do 166 | expect(configs.settings.to_yaml).to eq(expected_structure.to_yaml) 167 | end 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/unit/hati_config/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Configuration do 6 | describe '#configure' do 7 | let(:dummy_module) { Module.new { extend HatiConfig::Configuration } } 8 | 9 | context 'when using a block' do 10 | it 'configures settings correctly' do 11 | dummy_module.configure(:app_config) do 12 | config username: 'admin' 13 | config max_connections: 10, type: :int 14 | end 15 | 16 | expect(dummy_module.app_config.username).to eq('admin') 17 | end 18 | end 19 | 20 | context 'when loading from a hash' do 21 | it 'loads settings from a hash' do 22 | dummy_module.configure :app_config, hash: { username: 'admin', max_connections: 10 } 23 | 24 | aggregate_failures do 25 | expect(dummy_module.app_config.username).to eq('admin') 26 | expect(dummy_module.app_config.max_connections).to eq(10) 27 | end 28 | end 29 | end 30 | 31 | context 'when loading from a hash with schema' do 32 | let(:hash) { { username: 'admin', max_connections: 10 } } 33 | let(:schema) { { username: :str, max_connections: :int } } 34 | 35 | it 'loads settings from a hash with schema' do 36 | dummy_module.configure :app_config, hash: hash, schema: schema 37 | 38 | aggregate_failures do 39 | expect(dummy_module.app_config.username).to eq('admin') 40 | expect(dummy_module.app_config.max_connections).to eq(10) 41 | end 42 | end 43 | 44 | it 'raises SettingTypeError for invalid type' do 45 | hash[:max_connections] = '10' 46 | 47 | expect { dummy_module.configure(:app_config, hash: hash, schema: schema) } 48 | .to raise_error(HatiConfig::SettingTypeError) 49 | end 50 | end 51 | 52 | context 'when loading from JSON' do 53 | let(:json_data) { { username: 'admin', max_connections: 10 }.to_json } 54 | 55 | it 'loads settings from JSON' do 56 | dummy_module.configure(:app_config, json: json_data) 57 | 58 | aggregate_failures do 59 | expect(dummy_module.app_config.username).to eq('admin') 60 | expect(dummy_module.app_config.max_connections).to eq(10) 61 | end 62 | end 63 | end 64 | 65 | context 'when loading from YAML' do 66 | before do 67 | support_yaml_file_tempfile do |temp_file| 68 | dummy_module.configure(:app_config, yaml: temp_file.path) 69 | end 70 | end 71 | 72 | it 'loads settings from YAML' do 73 | aggregate_failures do 74 | expect(dummy_module.app_config.username).to eq('admin') 75 | expect(dummy_module.app_config.max_connections).to eq(10) 76 | end 77 | end 78 | end 79 | 80 | context 'when loading from remote sources' do 81 | let(:http_config) { { username: 'admin', max_connections: 10 } } 82 | let(:s3_config) { { database_url: 'postgres://localhost', pool_size: 5 } } 83 | let(:redis_config) { { feature_flags: { dark_mode: true } } } 84 | 85 | before do 86 | allow(HatiConfig::RemoteLoader).to receive(:from_http).and_return(http_config) 87 | allow(HatiConfig::RemoteLoader).to receive(:from_s3).and_return(s3_config) 88 | allow(HatiConfig::RemoteLoader).to receive(:from_redis).and_return(redis_config) 89 | end 90 | 91 | it 'loads settings from HTTP' do 92 | dummy_module.configure(:app_config, http: { 93 | url: 'https://config-server/config.json', 94 | headers: { 'Authorization' => 'Bearer token' } 95 | }) 96 | 97 | expect(dummy_module.app_config.username).to eq('admin') 98 | expect(dummy_module.app_config.max_connections).to eq(10) 99 | end 100 | 101 | it 'loads settings from S3' do 102 | dummy_module.configure(:app_config, s3: { 103 | bucket: 'my-configs', 104 | key: 'database.json', 105 | region: 'us-west-2' 106 | }) 107 | 108 | expect(dummy_module.app_config.database_url).to eq('postgres://localhost') 109 | expect(dummy_module.app_config.pool_size).to eq(5) 110 | end 111 | 112 | it 'loads settings from Redis' do 113 | dummy_module.configure(:app_config, redis: { 114 | host: 'redis.example.com', 115 | key: 'feature_flags' 116 | }) 117 | 118 | expect(dummy_module.app_config.feature_flags[:dark_mode]).to be true 119 | end 120 | end 121 | 122 | context 'when loading from invalid formats' do 123 | it 'raises LoadDataError for invalid JSON format' do 124 | expect do 125 | dummy_module.configure(:app_config, json: '{invalid_json}') 126 | end.to raise_error(HatiConfig::LoadDataError, 'Invalid JSON format') 127 | end 128 | 129 | it 'raises LoadDataError for non-existent YAML file' do 130 | expect do 131 | dummy_module.configure(:app_config, yaml: 'non_existent.yml') 132 | end.to raise_error(HatiConfig::LoadDataError, 'YAML file not found') 133 | end 134 | 135 | it 'raises LoadDataError for empty options' do 136 | expect do 137 | dummy_module.configure(:app_config, file: 'non_supported.txt') 138 | end.to raise_error(HatiConfig::LoadDataError, 'Invalid load source type') 139 | end 140 | end 141 | 142 | context 'when no options are provided' do 143 | it 'does not load any settings' do 144 | dummy_module.configure(:app_config) 145 | 146 | expect(dummy_module.respond_to?(:app_config)).to be true 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/hati_config/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # HatiConfig module provides functionality for managing HatiConfig features. 4 | module HatiConfig 5 | # This module handles configuration trees and loading data from various sources. 6 | module Configuration 7 | # Isolated module provides methods for handling isolated configurations. 8 | module Local 9 | # Configures an isolated config tree and tracks it 10 | # 11 | # @param config_tree_name [Symbol] The name of the configuration tree 12 | # @param opts [Hash] Options for configuration 13 | # @yield The configuration block 14 | # @return [self] 15 | # 16 | # @example 17 | # configure :isolated_settings do 18 | # config api_url: "https://api.example.com" 19 | # end 20 | # 21 | # # Example of accessing the configuration 22 | # puts MyApp.isolated_settings.api_url # => "https://api.example.com" 23 | def configure(config_tree_name, opts = {}, &block) 24 | super 25 | 26 | @isolated_configs ||= [] 27 | @isolated_configs << config_tree_name 28 | 29 | self 30 | end 31 | 32 | # Inherits isolated configurations to the base class 33 | # 34 | # @param base [Class] The inheriting class 35 | # 36 | # @example 37 | # class ChildClass < ParentClass 38 | # # Automatically inherits isolated configurations 39 | # end 40 | # 41 | # # Example of accessing inherited configurations 42 | # puts ChildClass.parent_settings.timeout # => 30 43 | def inherited(base) 44 | super 45 | 46 | @isolated_configs.each do |parent_config| 47 | hash = __send__(parent_config).to_h 48 | schema = __send__(parent_config).type_schema 49 | lock_schema = __send__(parent_config).lock_schema 50 | 51 | base.configure parent_config, hash: hash, schema: schema, lock_schema: lock_schema 52 | end 53 | end 54 | end 55 | 56 | # Creates a class-level method for the configuration tree. 57 | # 58 | # This method allows you to define a configuration tree using a block 59 | # or load configuration data from a hash, JSON, or YAML file. 60 | # 61 | # @param config_tree_name [Symbol, String] The name of the configuration method to be defined. 62 | # @param opts [Hash] Optional options for loading configuration data. 63 | # @option opts [Hash] :hash A hash containing configuration data. 64 | # @option opts [String] :json A JSON string containing configuration data. 65 | # @option opts [String] :yaml A file path to a YAML file containing configuration data. 66 | # @option opts [Hash] :schema A hash representing the type schema for the configuration. 67 | # @yield [Setting] A block that builds the configuration tree. 68 | # 69 | # @example Configuring with a block 70 | # configure :app_config do 71 | # config :username, value: "admin" 72 | # config :max_connections, type: :int, value: 10 73 | # end 74 | # 75 | # @example Loading from a hash 76 | # configure :app_config, hash: { username: "admin", max_connections: 10 } 77 | # 78 | # @example Loading from a JSON string 79 | # configure :app_config, json: '{"username": "admin", "max_connections": 10}' 80 | # 81 | # @example Loading from a YAML file 82 | # configure :app_config, yaml: 'config/settings.yml' 83 | # 84 | # @example Loading with a schema 85 | # configure :app_config, hash: { name: "admin", policy: "allow" }, schema: { name: :str, policy: :str } 86 | def configure(config_tree_name, opts = {}, &block) 87 | settings = block ? HatiConfig::Setting.new(&block) : HatiConfig::Setting.new 88 | 89 | load_configs = load_data(opts) unless opts.empty? 90 | settings.load_from_hash(load_configs, schema: opts[:schema], lock_schema: opts[:lock_schema]) if load_configs 91 | 92 | define_singleton_method(config_tree_name) do |&tree_block| 93 | tree_block ? settings.instance_eval(&tree_block) : settings 94 | end 95 | end 96 | 97 | module_function 98 | 99 | # Loads configuration data from various sources based on the provided options. 100 | # 101 | # @param opts [Hash] Optional options for loading configuration data. 102 | # @option opts [Hash] :hash A hash containing configuration data. 103 | # @option opts [String] :json A JSON string containing configuration data. 104 | # @option opts [String] :yaml A file path to a YAML file containing configuration data. 105 | # @return [Hash] The loaded configuration data. 106 | # @raise [LoadDataError] If no valid data is found. 107 | # 108 | # @example Loading from a hash 109 | # load_data(hash: { key: "value" }) 110 | # 111 | # @example Loading from a JSON string 112 | # load_data(json: '{"key": "value"}') 113 | # 114 | # @example Loading from a YAML file 115 | # load_data(yaml: 'config/settings.yml') 116 | def load_data(opts = {}) 117 | data = if opts[:hash] 118 | opts[:hash] 119 | elsif opts[:json] 120 | JSON.parse(opts[:json]) 121 | elsif opts[:yaml] 122 | YAML.load_file(opts[:yaml]) 123 | elsif opts[:http] 124 | RemoteLoader.from_http(**opts[:http]) 125 | elsif opts[:s3] 126 | RemoteLoader.from_s3(**opts[:s3]) 127 | elsif opts[:redis] 128 | RemoteLoader.from_redis(**opts[:redis]) 129 | end 130 | 131 | raise HatiConfig::LoadDataError, 'Invalid load source type' unless data 132 | 133 | data 134 | rescue JSON::ParserError 135 | raise HatiConfig::LoadDataError, 'Invalid JSON format' 136 | rescue Errno::ENOENT 137 | raise HatiConfig::LoadDataError, 'YAML file not found' 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/unit/hati_config/cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Cache do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Cache 9 | end 10 | end 11 | 12 | describe '#cache' do 13 | it 'creates a cache configuration with default values' do 14 | config = dummy_class.cache 15 | 16 | expect(config.adapter_type).to eq(:memory) 17 | expect(config.ttl).to eq(300) 18 | expect(config.stale_while_revalidate).to be false 19 | end 20 | 21 | it 'configures cache with a block' do 22 | config = dummy_class.cache do 23 | adapter :redis, url: 'redis://localhost:6379/0' 24 | ttl 600 25 | stale_while_revalidate true 26 | end 27 | 28 | expect(config.adapter_type).to eq(:redis) 29 | expect(config.adapter_options).to eq(url: 'redis://localhost:6379/0') 30 | expect(config.ttl).to eq(600) 31 | expect(config.stale_while_revalidate).to be true 32 | end 33 | end 34 | 35 | describe 'CacheConfig' do 36 | let(:config) { dummy_class.cache } 37 | 38 | describe '#refresh' do 39 | it 'configures refresh behavior' do 40 | config.refresh do 41 | interval 30 42 | jitter 5 43 | backoff do 44 | initial 2 45 | multiplier 3 46 | max 600 47 | end 48 | end 49 | 50 | expect(config.refresh.interval).to eq(30) 51 | expect(config.refresh.jitter).to eq(5) 52 | expect(config.refresh.backoff_config.initial).to eq(2) 53 | expect(config.refresh.backoff_config.multiplier).to eq(3) 54 | expect(config.refresh.backoff_config.max).to eq(600) 55 | end 56 | end 57 | 58 | describe 'MemoryAdapter' do 59 | let(:adapter) { config.adapter } 60 | 61 | it 'stores and retrieves values' do 62 | adapter.set('key', 'value', 60) 63 | expect(adapter.get('key')).to eq('value') 64 | end 65 | 66 | it 'respects TTL' do 67 | adapter.set('key', 'value', 0) 68 | expect(adapter.get('key')).to be_nil 69 | end 70 | 71 | it 'deletes values' do 72 | adapter.set('key', 'value', 60) 73 | adapter.delete('key') 74 | expect(adapter.get('key')).to be_nil 75 | end 76 | end 77 | 78 | describe 'RedisAdapter' do 79 | let(:redis_client) { instance_double(Redis) } 80 | let(:connection_pool) { instance_double(ConnectionPool) } 81 | let(:redis_config) do 82 | dummy_class.cache do 83 | adapter :redis, url: 'redis://localhost:6379/0' 84 | end 85 | end 86 | 87 | let(:adapter) { redis_config.adapter } 88 | 89 | before do 90 | allow(ConnectionPool).to receive(:new).and_return(connection_pool) 91 | allow(connection_pool).to receive(:with).and_yield(redis_client) 92 | allow(redis_client).to receive(:setex) 93 | allow(redis_client).to receive(:get) 94 | allow(redis_client).to receive(:del) 95 | end 96 | 97 | it 'stores and retrieves values' do 98 | value = 'value' 99 | serialized_value = Marshal.dump(value) 100 | allow(redis_client).to receive(:get).and_return(serialized_value) 101 | adapter.set('key', value, 60) 102 | expect(adapter.get('key')).to eq(value) 103 | expect(redis_client).to have_received(:setex).with('key', 60, serialized_value) 104 | end 105 | 106 | it 'respects TTL' do 107 | value = 'value' 108 | serialized_value = Marshal.dump(value) 109 | allow(redis_client).to receive(:get).and_return(serialized_value, nil) 110 | adapter.set('key', value, 1) 111 | expect(adapter.get('key')).to eq(value) 112 | # Simulate time passing, Redis handles actual expiry 113 | expect(adapter.get('key')).to be_nil 114 | end 115 | 116 | it 'deletes values' do 117 | value = 'value' 118 | adapter.set('key', value, 60) 119 | adapter.delete('key') 120 | expect(redis_client).to have_received(:del).with('key') 121 | end 122 | 123 | it 'handles complex objects' do 124 | value = { array: [1, 2, 3], hash: { key: 'value' } } 125 | serialized_value = Marshal.dump(value) 126 | allow(redis_client).to receive(:get).and_return(serialized_value) 127 | adapter.set('key', value, 60) 128 | expect(adapter.get('key')).to eq(value) 129 | end 130 | end 131 | end 132 | 133 | describe 'RefreshConfig' do 134 | let(:config) { dummy_class.cache.refresh } 135 | 136 | describe '#next_refresh_time' do 137 | it 'includes interval' do 138 | config.interval(30) 139 | config.jitter(0) 140 | 141 | next_time = config.next_refresh_time 142 | expect(next_time).to be_within(1).of(Time.now + 30) 143 | end 144 | 145 | it 'includes random jitter' do 146 | config.interval(30) 147 | config.jitter(10) 148 | 149 | times = 10.times.map { config.next_refresh_time } 150 | jitters = times.map { |t| t - (Time.now + 30) } 151 | 152 | expect(jitters.min).to be >= 0 153 | expect(jitters.max).to be <= 10 154 | expect(jitters.uniq.size).to be > 1 155 | end 156 | end 157 | end 158 | 159 | describe 'BackoffConfig' do 160 | let(:config) { dummy_class.cache.refresh.backoff_config } 161 | 162 | describe '#backoff_time' do 163 | before do 164 | config.initial(1) 165 | config.multiplier(2) 166 | config.max(8) 167 | end 168 | 169 | it 'starts with initial time' do 170 | expect(config.backoff_time(1)).to eq(1) 171 | end 172 | 173 | it 'applies multiplier for subsequent attempts' do 174 | expect(config.backoff_time(2)).to eq(2) 175 | expect(config.backoff_time(3)).to eq(4) 176 | expect(config.backoff_time(4)).to eq(8) 177 | end 178 | 179 | it 'respects maximum time' do 180 | expect(config.backoff_time(5)).to eq(8) 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/unit/hati_config/encryption_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe HatiConfig::Encryption do 6 | let(:dummy_class) do 7 | Class.new do 8 | extend HatiConfig::Encryption 9 | end 10 | end 11 | 12 | describe '#encryption' do 13 | it 'creates an encryption configuration with default values' do 14 | config = dummy_class.encryption 15 | 16 | expect(config.algorithm).to eq('aes') 17 | expect(config.key_size).to eq(256) 18 | expect(config.mode).to eq('gcm') 19 | expect(config.key_provider).to be_nil 20 | end 21 | 22 | it 'configures encryption with a block' do 23 | config = dummy_class.encryption do 24 | algorithm 'aes' 25 | key_size 128 26 | mode 'cbc' 27 | key_provider :env, env_var: 'MY_ENCRYPTION_KEY' 28 | end 29 | 30 | expect(config.algorithm).to eq('aes') 31 | expect(config.key_size).to eq(128) 32 | expect(config.mode).to eq('cbc') 33 | expect(config.key_provider).to be_a(HatiConfig::Encryption::EnvKeyProvider) 34 | end 35 | end 36 | 37 | describe 'EncryptionConfig' do 38 | let(:config) { dummy_class.encryption } 39 | 40 | describe '#encrypt and #decrypt' do 41 | before do 42 | ENV['HATI_CONFIG_ENCRYPTION_KEY'] = '0' * 32 # 256-bit key 43 | config.key_provider :env 44 | end 45 | 46 | after do 47 | ENV.delete('HATI_CONFIG_ENCRYPTION_KEY') 48 | end 49 | 50 | it 'encrypts and decrypts values' do 51 | value = 'sensitive data' 52 | encrypted = config.encrypt(value) 53 | expect(encrypted).not_to eq(value) 54 | expect(config.decrypt(encrypted)).to eq(value) 55 | end 56 | 57 | it 'encrypts and decrypts non-string values' do 58 | value = { key: 'value', number: 123 } 59 | encrypted = config.encrypt(value) 60 | expect(encrypted).not_to eq(value.to_s) 61 | expect(config.decrypt(encrypted)).to eq(value.to_s) 62 | end 63 | 64 | it 'raises error when decrypting invalid data' do 65 | expect { config.decrypt('invalid base64!') } 66 | .to raise_error(HatiConfig::Encryption::EncryptionError, /Invalid encrypted value/) 67 | end 68 | 69 | it 'raises error when no key provider is configured' do 70 | config = HatiConfig::Encryption::EncryptionConfig.new 71 | expect { config.encrypt('value') } 72 | .to raise_error(HatiConfig::Encryption::EncryptionError, /No key provider configured/) 73 | end 74 | end 75 | 76 | describe 'KeyProvider' do 77 | describe '.create' do 78 | it 'creates an EnvKeyProvider' do 79 | provider = HatiConfig::Encryption::KeyProvider.create(:env, env_var: 'MY_KEY') 80 | expect(provider).to be_a(HatiConfig::Encryption::EnvKeyProvider) 81 | end 82 | 83 | it 'creates a FileKeyProvider' do 84 | provider = HatiConfig::Encryption::KeyProvider.create(:file, file_path: '/path/to/key') 85 | expect(provider).to be_a(HatiConfig::Encryption::FileKeyProvider) 86 | end 87 | 88 | it 'creates an AwsKmsKeyProvider' do 89 | provider = HatiConfig::Encryption::KeyProvider.create(:aws_kms, key_id: 'key-id', region: 'us-west-2') 90 | expect(provider).to be_a(HatiConfig::Encryption::AwsKmsKeyProvider) 91 | end 92 | 93 | it 'raises error for unknown provider type' do 94 | expect { HatiConfig::Encryption::KeyProvider.create(:unknown) } 95 | .to raise_error(HatiConfig::Encryption::EncryptionError, /Unknown key provider/) 96 | end 97 | end 98 | end 99 | 100 | describe 'EnvKeyProvider' do 101 | let(:provider) { HatiConfig::Encryption::KeyProvider.create(:env, env_var: 'MY_KEY') } 102 | 103 | it 'gets key from environment variable' do 104 | ENV['MY_KEY'] = 'test-key' 105 | expect(provider.key).to eq('test-key') 106 | ENV.delete('MY_KEY') 107 | end 108 | 109 | it 'raises error when environment variable is not set' do 110 | ENV.delete('MY_KEY') 111 | expect { provider.key } 112 | .to raise_error(HatiConfig::Encryption::EncryptionError, /not found in environment variable/) 113 | end 114 | end 115 | 116 | describe 'FileKeyProvider' do 117 | let(:provider) { HatiConfig::Encryption::KeyProvider.create(:file, file_path: 'test.key') } 118 | 119 | it 'gets key from file' do 120 | allow(File).to receive(:exist?).with('test.key').and_return(true) 121 | allow(File).to receive(:read).with('test.key').and_return("test-key\n") 122 | expect(provider.key).to eq('test-key') 123 | end 124 | 125 | it 'raises error when file does not exist' do 126 | allow(File).to receive(:exist?).with('test.key').and_return(false) 127 | expect { provider.key } 128 | .to raise_error(HatiConfig::Encryption::EncryptionError, /Key file not found/) 129 | end 130 | 131 | it 'raises error when file cannot be read' do 132 | allow(File).to receive(:exist?).with('test.key').and_return(true) 133 | allow(File).to receive(:read).with('test.key').and_raise(SystemCallError.new('Permission denied')) 134 | expect { provider.key } 135 | .to raise_error(HatiConfig::Encryption::EncryptionError, /Failed to read key file/) 136 | end 137 | end 138 | 139 | describe 'AwsKmsKeyProvider' do 140 | let(:provider) { HatiConfig::Encryption::KeyProvider.create(:aws_kms, key_id: 'key-id', region: 'us-west-2') } 141 | let(:kms_client) { instance_double(Aws::KMS::Client) } 142 | let(:kms_response) { instance_double(Aws::KMS::Types::GenerateDataKeyResponse, plaintext: 'kms-key') } 143 | 144 | before do 145 | allow(Aws::KMS::Client).to receive(:new).and_return(kms_client) 146 | allow(kms_client).to receive(:generate_data_key).and_return(kms_response) 147 | end 148 | 149 | it 'gets key from AWS KMS' do 150 | expect(provider.key).to eq('kms-key') 151 | expect(kms_client).to have_received(:generate_data_key) 152 | .with(key_id: 'key-id', key_spec: 'AES_256') 153 | end 154 | 155 | it 'raises error when KMS request fails' do 156 | allow(kms_client).to receive(:generate_data_key) 157 | .and_raise(Aws::KMS::Errors::ServiceError.new(nil, 'KMS error')) 158 | expect { provider.key } 159 | .to raise_error(HatiConfig::Encryption::EncryptionError, /Failed to get key from KMS/) 160 | end 161 | 162 | it 'caches the key' do 163 | 2.times { provider.key } 164 | expect(kms_client).to have_received(:generate_data_key).once 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/hati_config/encryption.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | require 'base64' 5 | 6 | module HatiConfig 7 | # Encryption module provides methods for encrypting and decrypting sensitive configuration values. 8 | module Encryption 9 | # Custom error class for encryption-related errors. 10 | class EncryptionError < StandardError; end 11 | 12 | # Defines encryption configuration for a class or module. 13 | # 14 | # @yield [encryption_config] A block to configure encryption settings. 15 | # @return [EncryptionConfig] The encryption configuration instance. 16 | def encryption(&block) 17 | @encryption_config ||= EncryptionConfig.new 18 | @encryption_config.instance_eval(&block) if block_given? 19 | @encryption_config 20 | end 21 | 22 | # Gets the encryption configuration. 23 | # 24 | # @return [EncryptionConfig] The encryption configuration 25 | def encryption_config 26 | @encryption_config ||= EncryptionConfig.new 27 | end 28 | 29 | # EncryptionConfig class handles encryption configuration and behavior. 30 | class EncryptionConfig 31 | attr_reader :key_provider, :algorithm, :key_size, :mode, :key_provider_type, :key_provider_options 32 | 33 | def initialize 34 | @key_provider = nil 35 | @key_provider_type = nil 36 | @key_provider_options = {} 37 | @algorithm = 'aes' 38 | @key_size = 256 39 | @mode = 'gcm' 40 | end 41 | 42 | # Sets the key provider. 43 | # 44 | # @param provider [Symbol] The key provider type (:env, :file, :aws_kms) 45 | # @param options [Hash] Options for the key provider 46 | def key_provider(provider = nil, options = {}) 47 | if provider.nil? 48 | @key_provider 49 | else 50 | @key_provider_type = provider 51 | @key_provider_options = options 52 | @key_provider = KeyProvider.create(provider, options) 53 | self 54 | end 55 | end 56 | 57 | # Sets the encryption algorithm. 58 | # 59 | # @param value [String] The encryption algorithm (e.g., "aes") 60 | def algorithm(value = nil) 61 | if value.nil? 62 | @algorithm 63 | else 64 | @algorithm = value 65 | self 66 | end 67 | end 68 | 69 | # Sets the key size. 70 | # 71 | # @param value [Integer] The key size in bits (e.g., 256) 72 | def key_size(value = nil) 73 | if value.nil? 74 | @key_size 75 | else 76 | @key_size = value 77 | self 78 | end 79 | end 80 | 81 | # Sets the encryption mode. 82 | # 83 | # @param value [String] The encryption mode (e.g., "gcm") 84 | def mode(value = nil) 85 | if value.nil? 86 | @mode 87 | else 88 | @mode = value 89 | self 90 | end 91 | end 92 | 93 | # Encrypts a value. 94 | # 95 | # @param value [String] The value to encrypt 96 | # @return [String] The encrypted value in Base64 format 97 | # @raise [EncryptionError] If encryption fails 98 | def encrypt(value) 99 | raise EncryptionError, 'No key provider configured' unless @key_provider 100 | 101 | begin 102 | cipher = OpenSSL::Cipher.new("#{@algorithm}-#{@key_size}-#{@mode}") 103 | cipher.encrypt 104 | cipher.key = @key_provider.key 105 | 106 | if @mode == 'gcm' 107 | cipher.auth_data = '' 108 | iv = cipher.random_iv 109 | cipher.iv = iv 110 | ciphertext = cipher.update(value.to_s) + cipher.final 111 | auth_tag = cipher.auth_tag 112 | 113 | # Format: Base64(IV + Auth Tag + Ciphertext) 114 | Base64.strict_encode64(iv + auth_tag + ciphertext) 115 | else 116 | iv = cipher.random_iv 117 | cipher.iv = iv 118 | ciphertext = cipher.update(value.to_s) + cipher.final 119 | 120 | # Format: Base64(IV + Ciphertext) 121 | Base64.strict_encode64(iv + ciphertext) 122 | end 123 | rescue OpenSSL::Cipher::CipherError => e 124 | raise EncryptionError, "Encryption failed: #{e.message}" 125 | end 126 | end 127 | 128 | # Decrypts a value. 129 | # 130 | # @param encrypted_value [String] The encrypted value in Base64 format 131 | # @return [String] The decrypted value 132 | # @raise [EncryptionError] If decryption fails 133 | def decrypt(encrypted_value) 134 | raise EncryptionError, 'No key provider configured' unless @key_provider 135 | return nil if encrypted_value.nil? 136 | 137 | begin 138 | data = Base64.strict_decode64(encrypted_value) 139 | cipher = OpenSSL::Cipher.new("#{@algorithm}-#{@key_size}-#{@mode}") 140 | cipher.decrypt 141 | cipher.key = @key_provider.key 142 | 143 | if @mode == 'gcm' 144 | iv = data[0, 12] # GCM uses 12-byte IV 145 | auth_tag = data[12, 16] # GCM uses 16-byte auth tag 146 | ciphertext = data[28..] 147 | 148 | cipher.iv = iv 149 | cipher.auth_tag = auth_tag 150 | cipher.auth_data = '' 151 | 152 | else 153 | iv = data[0, 16] # Other modes typically use 16-byte IV 154 | ciphertext = data[16..] 155 | 156 | cipher.iv = iv 157 | end 158 | cipher.update(ciphertext) + cipher.final 159 | rescue OpenSSL::Cipher::CipherError => e 160 | raise EncryptionError, "Decryption failed: #{e.message}" 161 | rescue ArgumentError => e 162 | raise EncryptionError, "Invalid encrypted value: #{e.message}" 163 | end 164 | end 165 | end 166 | 167 | # KeyProvider class hierarchy for handling encryption keys. 168 | class KeyProvider 169 | def self.create(type, options = {}) 170 | case type 171 | when :env 172 | EnvKeyProvider.new(options) 173 | when :file 174 | FileKeyProvider.new(options) 175 | when :aws_kms 176 | AwsKmsKeyProvider.new(options) 177 | else 178 | raise EncryptionError, "Unknown key provider: #{type}" 179 | end 180 | end 181 | 182 | def key 183 | raise NotImplementedError, 'Subclasses must implement #key' 184 | end 185 | end 186 | 187 | # EnvKeyProvider gets the encryption key from an environment variable. 188 | class EnvKeyProvider < KeyProvider 189 | def initialize(options = {}) 190 | super() 191 | @env_var = options[:env_var] || 'HATI_CONFIG_ENCRYPTION_KEY' 192 | end 193 | 194 | def key 195 | key = ENV.fetch(@env_var, nil) 196 | raise EncryptionError, "Encryption key not found in environment variable #{@env_var}" unless key 197 | 198 | key 199 | end 200 | end 201 | 202 | # FileKeyProvider gets the encryption key from a file. 203 | class FileKeyProvider < KeyProvider 204 | def initialize(options = {}) 205 | super() 206 | @file_path = options[:file_path] 207 | raise EncryptionError, 'File path not provided' unless @file_path 208 | end 209 | 210 | def key 211 | raise EncryptionError, "Key file not found: #{@file_path}" unless File.exist?(@file_path) 212 | 213 | File.read(@file_path).strip 214 | rescue SystemCallError => e 215 | raise EncryptionError, "Failed to read key file: #{e.message}" 216 | end 217 | end 218 | 219 | # AwsKmsKeyProvider gets the encryption key from AWS KMS. 220 | class AwsKmsKeyProvider < KeyProvider 221 | def initialize(options = {}) 222 | super() 223 | require 'aws-sdk-kms' 224 | @key_id = options[:key_id] 225 | @region = options[:region] 226 | @client = nil 227 | raise EncryptionError, 'KMS key ID not provided' unless @key_id 228 | end 229 | 230 | def key 231 | @key ||= begin 232 | client = Aws::KMS::Client.new(region: @region) 233 | response = client.generate_data_key( 234 | key_id: @key_id, 235 | key_spec: 'AES_256' 236 | ) 237 | response.plaintext 238 | rescue Aws::KMS::Errors::ServiceError => e 239 | raise EncryptionError, "Failed to get key from KMS: #{e.message}" 240 | end 241 | end 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /lib/hati_config/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HatiConfig 4 | # Schema module provides functionality for managing configuration schemas and versioning. 5 | module Schema 6 | # Defines a schema for configuration validation. 7 | # 8 | # @param version [String] The schema version (e.g., "1.0", "2.0") 9 | # @yield The schema definition block 10 | # @example 11 | # schema version: "1.0" do 12 | # required :database_url, type: :string 13 | # optional :pool_size, type: :integer, default: 5 14 | # deprecated :old_setting, since: "1.0", remove_in: "2.0" 15 | # end 16 | def schema(version: '1.0', &block) 17 | @schema_version = version 18 | @schema_definition = SchemaDefinition.new(version) 19 | @schema_definition.instance_eval(&block) if block_given? 20 | @schema_definition 21 | end 22 | 23 | # Gets the current schema version. 24 | # 25 | # @return [String] The current schema version 26 | def schema_version 27 | @schema_version || '1.0' 28 | end 29 | 30 | # Gets the schema definition. 31 | # 32 | # @return [SchemaDefinition] The schema definition 33 | def schema_definition 34 | @schema_definition ||= SchemaDefinition.new(schema_version) 35 | end 36 | 37 | # Defines a migration between schema versions. 38 | # 39 | # @param versions [Hash] The from and to versions (e.g., "1.0" => "2.0") 40 | # @yield [config] The migration block 41 | # @example 42 | # migration "1.0" => "2.0" do |config| 43 | # config.replica_urls = [config.delete(:backup_url)].compact 44 | # end 45 | def migration(versions, &block) 46 | schema_definition.add_migration(versions, block) 47 | end 48 | 49 | # SchemaDefinition class handles schema validation and migration. 50 | class SchemaDefinition 51 | attr_reader :version, :required_fields, :optional_fields, :deprecated_fields, :migrations 52 | 53 | def initialize(version) 54 | @version = version 55 | @required_fields = {} 56 | @optional_fields = {} 57 | @deprecated_fields = {} 58 | @migrations = {} 59 | end 60 | 61 | # Defines a field in the schema. 62 | # 63 | # @param name [Symbol] The field name 64 | # @param type [Symbol, Class] The field type 65 | # @param required [Boolean] Whether the field is required 66 | # @param default [Object] The default value for optional fields 67 | # @param since [String] The version since this field is available 68 | def field(name, type:, required: true, default: nil, since: version) 69 | if required 70 | required(name, type: type, since: since) 71 | else 72 | optional(name, type: type, default: default, since: since) 73 | end 74 | end 75 | 76 | # Defines a required field in the schema. 77 | # 78 | # @param name [Symbol] The field name 79 | # @param type [Symbol, Class] The field type 80 | # @param since [String] The version since this field is required 81 | def required(name, type:, since: version) 82 | @required_fields[name] = { type: type, since: since } 83 | end 84 | 85 | # Defines an optional field in the schema. 86 | # 87 | # @param name [Symbol] The field name 88 | # @param type [Symbol, Class] The field type 89 | # @param default [Object] The default value 90 | # @param since [String] The version since this field is available 91 | def optional(name, type:, default: nil, since: version) 92 | @optional_fields[name] = { type: type, default: default, since: since } 93 | end 94 | 95 | # Marks a field as deprecated. 96 | # 97 | # @param name [Symbol] The field name 98 | # @param since [String] The version since this field is deprecated 99 | # @param remove_in [String] The version when this field will be removed 100 | def deprecated(name, since:, remove_in:) 101 | @deprecated_fields[name] = { since: since, remove_in: remove_in } 102 | end 103 | 104 | # Adds a migration between versions. 105 | # 106 | # @param from_version [String] The source version 107 | # @param to_version [String] The target version 108 | # @param block [Proc] The migration block 109 | def add_migration(from_version, to_version = nil, block = nil, &implicit_block) 110 | if block.nil? && to_version.respond_to?(:call) 111 | # Handle the case where to_version is the block 112 | block = to_version 113 | if from_version.is_a?(Hash) 114 | from_version, to_version = from_version.first 115 | else 116 | from_version, to_version = from_version.to_s.gsub(/['"{}]/, '').split('=>').map(&:strip) 117 | end 118 | end 119 | 120 | migration_block = block || implicit_block 121 | raise MigrationError, 'Invalid migration format' unless from_version && to_version && migration_block 122 | 123 | key = migration_key(from_version, to_version) 124 | @migrations[key] = migration_block 125 | end 126 | 127 | def migration(versions, &block) 128 | if versions.is_a?(Hash) 129 | from_version, to_version = versions.first 130 | else 131 | from_version, to_version = versions.to_s.gsub(/['"{}]/, '').split('=>').map(&:strip) 132 | end 133 | raise MigrationError, 'Invalid migration format' unless from_version && to_version 134 | 135 | add_migration(from_version, to_version, nil, &block) 136 | end 137 | 138 | # Validates configuration data against the schema. 139 | # 140 | # @param data [Hash] The configuration data to validate 141 | # @param current_version [String] The current schema version 142 | # @raise [ValidationError] If validation fails 143 | def validate(data, current_version = version) 144 | validate_required_fields(data, current_version) 145 | validate_deprecated_fields(data, current_version) 146 | validate_types(data) 147 | end 148 | 149 | # Migrates configuration data from one version to another. 150 | # 151 | # @param data [Hash] The configuration data to migrate 152 | # @param from_version [String] The source version 153 | # @param to_version [String] The target version 154 | # @return [Hash] The migrated configuration data 155 | def migrate(data, from_version, to_version) 156 | key = migration_key(from_version, to_version) 157 | migration = migrations[key] 158 | raise MigrationError, "No migration path from #{from_version} to #{to_version}" unless migration 159 | 160 | data = data.dup 161 | migration.call(data) 162 | data 163 | end 164 | 165 | private 166 | 167 | def migration_key(from_version, to_version) 168 | "#{from_version}-#{to_version}" 169 | end 170 | 171 | def validate_required_fields(data, current_version) 172 | required_fields.each do |name, field| 173 | next if field[:since] > current_version 174 | next if data.key?(name) 175 | 176 | raise ValidationError, "Missing required field: #{name}" 177 | end 178 | end 179 | 180 | def validate_deprecated_fields(data, current_version) 181 | deprecated_fields.each do |name, field| 182 | next unless data.key?(name) 183 | next unless field[:since] <= current_version 184 | 185 | if field[:remove_in] <= current_version 186 | raise ValidationError, "Field #{name} was removed in version #{field[:remove_in]}" 187 | end 188 | 189 | warn "Field #{name} is deprecated since version #{field[:since]} and will be removed in #{field[:remove_in]}" 190 | end 191 | end 192 | 193 | def validate_types(data) 194 | all_fields = required_fields.merge(optional_fields) 195 | data.each do |name, value| 196 | field = all_fields[name] 197 | next unless field 198 | 199 | type = field[:type] 200 | next if TypeChecker.call(value, type: type) 201 | 202 | raise ValidationError, "Invalid type for field #{name}: expected #{type}, got #{value.class}" 203 | end 204 | end 205 | end 206 | 207 | # Error raised when schema validation fails. 208 | class ValidationError < StandardError; end 209 | 210 | # Error raised when schema migration fails. 211 | class MigrationError < StandardError; end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/hati_config/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'redis' 4 | require 'connection_pool' 5 | require 'json' 6 | 7 | module HatiConfig 8 | # Cache module provides functionality for caching and refreshing configurations. 9 | module Cache 10 | # Module for handling numeric configuration attributes 11 | module NumericConfigurable 12 | def self.included(base) 13 | base.extend(ClassMethods) 14 | end 15 | 16 | module ClassMethods 17 | def numeric_accessor(*names) 18 | names.each do |name| 19 | define_method(name) do |value = nil| 20 | if value.nil? 21 | instance_variable_get(:"@#{name}") 22 | else 23 | instance_variable_set(:"@#{name}", value) 24 | self 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | 32 | # Defines caching behavior for configurations. 33 | # 34 | # @param adapter [Symbol] The cache adapter to use (:memory, :redis) 35 | # @param options [Hash] Options for the cache adapter 36 | # @yield The cache configuration block 37 | # @example 38 | # cache do 39 | # adapter :redis, url: "redis://cache.example.com:6379/0" 40 | # ttl 300 # 5 minutes 41 | # stale_while_revalidate true 42 | # end 43 | def cache(&block) 44 | @cache_config = CacheConfig.new 45 | @cache_config.instance_eval(&block) if block_given? 46 | @cache_config 47 | end 48 | 49 | # Gets the cache configuration. 50 | # 51 | # @return [CacheConfig] The cache configuration 52 | def cache_config 53 | @cache_config ||= CacheConfig.new 54 | end 55 | 56 | # CacheConfig class handles cache configuration and behavior. 57 | class CacheConfig 58 | include NumericConfigurable 59 | 60 | attr_reader :adapter_type, :adapter_options 61 | 62 | numeric_accessor :ttl 63 | 64 | def refresh(&block) 65 | @refresh_config.instance_eval(&block) if block_given? 66 | @refresh_config 67 | end 68 | 69 | def stale_while_revalidate(enabled = nil) 70 | if enabled.nil? 71 | @stale_while_revalidate 72 | else 73 | @stale_while_revalidate = enabled 74 | self 75 | end 76 | end 77 | 78 | def initialize 79 | @adapter_type = :memory 80 | @adapter_options = {} 81 | @ttl = 300 82 | @stale_while_revalidate = false 83 | @refresh_config = RefreshConfig.new 84 | @adapter = nil 85 | end 86 | 87 | # Sets the cache adapter. 88 | # 89 | # @param type [Symbol] The adapter type (:memory, :redis) 90 | # @param options [Hash] Options for the adapter 91 | def adapter(*args, **kwargs) 92 | if args.empty? && kwargs.empty? 93 | @adapter ||= initialize_adapter 94 | else 95 | type = args[0] 96 | options = args[1] || kwargs 97 | @adapter_type = type 98 | @adapter_options = options 99 | @adapter = nil 100 | self 101 | end 102 | end 103 | 104 | attr_writer :adapter 105 | 106 | private 107 | 108 | def initialize_adapter 109 | case adapter_type 110 | when :memory 111 | MemoryAdapter.new 112 | when :redis 113 | RedisAdapter.new(adapter_options) 114 | else 115 | raise ArgumentError, "Unknown cache adapter: #{adapter_type}" 116 | end 117 | end 118 | 119 | # Gets a value from the cache. 120 | # 121 | # @param key [String] The cache key 122 | # @return [Object, nil] The cached value or nil if not found 123 | def get(key) 124 | adapter.get(key) 125 | end 126 | 127 | # Sets a value in the cache. 128 | # 129 | # @param key [String] The cache key 130 | # @param value [Object] The value to cache 131 | # @param ttl [Integer, nil] Optional TTL override 132 | def set(key, value, ttl = nil) 133 | adapter.set(key, value, ttl || @ttl) 134 | end 135 | 136 | # Deletes a value from the cache. 137 | # 138 | # @param key [String] The cache key 139 | def delete(key) 140 | adapter.delete(key) 141 | end 142 | end 143 | 144 | # RefreshConfig class handles refresh configuration and behavior. 145 | class RefreshConfig 146 | include NumericConfigurable 147 | 148 | attr_reader :interval, :jitter, :backoff_config 149 | 150 | numeric_accessor :interval, :jitter 151 | 152 | def initialize 153 | @interval = 60 154 | @jitter = 0 155 | @backoff_config = BackoffConfig.new 156 | end 157 | 158 | # Configures backoff behavior. 159 | # 160 | # @yield The backoff configuration block 161 | def backoff(&block) 162 | @backoff_config.instance_eval(&block) if block_given? 163 | @backoff_config 164 | end 165 | 166 | # Gets the next refresh time. 167 | # 168 | # @return [Time] The next refresh time 169 | def next_refresh_time 170 | jitter_amount = jitter.positive? ? rand(0.0..jitter) : 0 171 | Time.now + interval + jitter_amount 172 | end 173 | end 174 | 175 | # BackoffConfig class handles backoff configuration and behavior. 176 | class BackoffConfig 177 | include NumericConfigurable 178 | 179 | attr_reader :initial, :multiplier, :max 180 | 181 | numeric_accessor :initial, :multiplier, :max 182 | 183 | def initialize 184 | @initial = 1 185 | @multiplier = 2 186 | @max = 300 187 | end 188 | 189 | # Gets the backoff time for a given attempt. 190 | # 191 | # @param attempt [Integer] The attempt number 192 | # @return [Integer] The backoff time in seconds 193 | def backoff_time(attempt) 194 | time = initial * (multiplier**(attempt - 1)) 195 | [time, max].min 196 | end 197 | end 198 | 199 | # MemoryAdapter class provides in-memory caching. 200 | class MemoryAdapter 201 | def initialize 202 | @store = {} 203 | @expiry = {} 204 | end 205 | 206 | # Gets a value from the cache. 207 | # 208 | # @param key [String] The cache key 209 | # @return [Object, nil] The cached value or nil if not found/expired 210 | def get(key) 211 | return nil if expired?(key) 212 | 213 | @store[key] 214 | end 215 | 216 | # Sets a value in the cache. 217 | # 218 | # @param key [String] The cache key 219 | # @param value [Object] The value to cache 220 | # @param ttl [Integer] The TTL in seconds 221 | def set(key, value, ttl) 222 | @store[key] = value 223 | @expiry[key] = Time.now + ttl if ttl 224 | end 225 | 226 | # Deletes a value from the cache. 227 | # 228 | # @param key [String] The cache key 229 | def delete(key) 230 | @store.delete(key) 231 | @expiry.delete(key) 232 | end 233 | 234 | private 235 | 236 | def expired?(key) 237 | expiry = @expiry[key] 238 | expiry && Time.now >= expiry 239 | end 240 | end 241 | 242 | # RedisAdapter class provides Redis-based caching. 243 | class RedisAdapter 244 | def initialize(options) 245 | @pool = ConnectionPool.new(size: 5, timeout: 5) do 246 | Redis.new(options) 247 | end 248 | end 249 | 250 | # Gets a value from the cache. 251 | # 252 | # @param key [String] The cache key 253 | # @return [Object, nil] The cached value or nil if not found 254 | def get(key) 255 | @pool.with do |redis| 256 | value = redis.get(key) 257 | value ? Marshal.load(value) : nil 258 | end 259 | rescue TypeError, ArgumentError 260 | nil 261 | end 262 | 263 | # Sets a value in the cache. 264 | # 265 | # @param key [String] The cache key 266 | # @param value [Object] The value to cache 267 | # @param ttl [Integer] The TTL in seconds 268 | def set(key, value, ttl) 269 | @pool.with do |redis| 270 | redis.setex(key, ttl, Marshal.dump(value)) 271 | end 272 | end 273 | 274 | # Deletes a value from the cache. 275 | # 276 | # @param key [String] The cache key 277 | def delete(key) 278 | @pool.with do |redis| 279 | redis.del(key) 280 | end 281 | end 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /OVERVIEW.md: -------------------------------------------------------------------------------- 1 | # Configuration Patterns & HatiConfig Overview 2 | 3 | ## Configuration Patterns in Ruby 4 | 5 | Every application has different needs, and there are many excellent ways to handle configuration. Here's our perspective on what we've seen work well: 6 | 7 | ### The Classic Approach (Simple & Proven) 8 | 9 | Many applications work perfectly with traditional patterns: 10 | 11 | ``` 12 | ├── Configuration Needs 13 | │ ├── Database URL 14 | │ ├── API Keys 15 | │ ├── Feature Flags 16 | │ └── Environment-specific settings 17 | │ 18 | ├── Team Structure 19 | │ ├── Single team 20 | │ └── Clear ownership 21 | │ 22 | ├── Deployment 23 | │ ├── Standard Rails/Ruby app 24 | │ ├── Config changes are rare 25 | │ └── Deployment downtime is acceptable 26 | │ 27 | └── Tools That Work Great Here 28 | ├── ENV variables 29 | ├── Rails credentials 30 | ├── AWS Parameter Store 31 | └── YAML configs 32 | ``` 33 | 34 | This is a sweet spot where simple solutions shine. A typical pattern might look like: 35 | 36 | ```ruby 37 | # config/application.rb - Clean and effective 38 | module MyApp 39 | class Application < Rails::Application 40 | config.database_url = ENV['DATABASE_URL'] 41 | config.redis_url = ENV['REDIS_URL'] 42 | config.api_key = Rails.application.credentials.api[:key] 43 | end 44 | end 45 | ``` 46 | 47 | ### When Configuration Gets Interesting 48 | 49 | Sometimes you encounter scenarios that push beyond the basics: 50 | 51 | ```ruby 52 | # A real-world pattern we've seen 53 | class ServiceConfig 54 | def initialize 55 | # Different sources for different needs 56 | @rate_limits = fetch_from_redis('api:limits') 57 | @feature_flags = load_from_s3('features.yml') 58 | 59 | # Region-specific settings 60 | @timeout = region_specific? ? 61 | fetch_from_ssm("/#{region}/timeout") : 62 | DEFAULT_TIMEOUT 63 | 64 | # Team-specific overrides 65 | @quotas = team_config.deep_merge(global_quotas) 66 | end 67 | end 68 | ``` 69 | 70 | This is where configuration starts getting more nuanced. You might recognize your situation if you see: 71 | 72 | ``` 73 | ├── Configuration Sources 74 | │ ├── Redis (real-time updates) 75 | │ ├── S3 (large config files) 76 | │ ├── Parameter Store (secrets) 77 | │ └── HTTP APIs (external services) 78 | │ 79 | ├── Team & Service Scale 80 | │ ├── Multiple engineering teams 81 | │ │ ├── Frontend 82 | │ │ ├── Backend 83 | │ │ └── Data Science 84 | │ └── Many microservices 85 | │ ├── Each with own configs 86 | │ └── Cross-service dependencies 87 | │ 88 | ├── Dynamic Requirements 89 | │ ├── Feature flags need instant updates 90 | │ ├── A/B tests change frequently 91 | │ ├── Rate limits adjust in real-time 92 | │ └── Resource quotas vary by team 93 | │ 94 | └── Complex Environments 95 | ├── Development 96 | ├── Staging 97 | ├── Production 98 | │ ├── US East 99 | │ ├── US West 100 | │ └── EU Region 101 | └── Each with unique overrides 102 | ``` 103 | 104 | This is the kind of scenario where HatiConfig might be worth considering. Here's how these challenges often manifest in code: 105 | 106 | ```ruby 107 | # A pattern we often see evolve organically 108 | class ConfigurationService 109 | def initialize(region:, team:, environment:) 110 | # Multiple sources to check 111 | @redis = Redis.new 112 | @s3_client = Aws::S3::Client.new 113 | @ssm = Aws::SSM::Client.new 114 | 115 | # Complex context handling 116 | @region = region 117 | @team = team 118 | @environment = environment 119 | 120 | # Caching and refresh logic 121 | @cache = {} 122 | @last_refresh = {} 123 | @refresh_intervals = { 124 | rate_limits: 30, # 30 seconds 125 | features: 300, # 5 minutes 126 | quotas: 3600 # 1 hour 127 | } 128 | end 129 | 130 | def get_config(key) 131 | return @cache[key] if cache_valid?(key) 132 | 133 | value = case key 134 | when /^rate_limit/ 135 | fetch_from_redis(key) 136 | when /^feature_flag/ 137 | load_from_s3("#{@team}/#{key}") 138 | else 139 | fetch_from_ssm("/#{@environment}/#{key}") 140 | end 141 | 142 | @cache[key] = value 143 | @last_refresh[key] = Time.now 144 | value 145 | end 146 | end 147 | ``` 148 | 149 | If this looks familiar, you might find HatiConfig's approach interesting. 150 | 151 | --- 152 | 153 | ## HatiConfig's Approach 154 | 155 | Here's how we tackle these challenges - it's one way to solve the problem, inspired by patterns we've seen work well in production: 156 | 157 | ```ruby 158 | # One possible approach to complex configs 159 | config = HatiConfig::Setting.new do 160 | # Type-safe configuration with validation 161 | config :rate_limit, value: 1000, type: :integer 162 | 163 | # Team isolation through namespaces 164 | configure :team_a do 165 | config :feature_flags, source: :redis 166 | end 167 | 168 | # Environment inheritance 169 | environment :production do 170 | configure :us_east do 171 | config :timeout, value: 30 172 | end 173 | end 174 | end 175 | ``` 176 | 177 | ### Key Design Decisions 178 | 179 | | Pattern | Our Approach | Why | 180 | | --------------------- | ------------------------------------------ | -------------------------------------------- | 181 | | **Config Sources** | HTTP, S3, Redis with auto-refresh | Different sources for different update needs | 182 | | **Environment Model** | Inheritance-based with overrides | Natural mapping to deployment environments | 183 | | **Team Isolation** | Namespace-based with explicit boundaries | Prevents accidental cross-team interference | 184 | | **Type System** | Runtime validation with schema versioning | Catches errors before they hit production | 185 | | **Security Model** | Multi-provider with transparent encryption | Flexibility in key management | 186 | | **Update Strategy** | Background refresh with circuit breakers | Resilient to source outages | 187 | | **Data Format** | Ruby DSL with YAML/JSON import/export | Native Ruby feel with serialization options | 188 | 189 | --- 190 | 191 | ## Real-World Case Study: When Simple Tools Fail 192 | 193 | **Company**: E-commerce platform with 2M+ daily active users 194 | **Scale**: 80+ microservices, 12 engineering teams, 6 AWS regions 195 | **Problem**: Black Friday traffic spike caused site outage due to config management limitations 196 | 197 | ### The Scenario 198 | 199 | During Black Friday 2023, traffic spiked 10x normal levels. The platform needed to: 200 | 201 | 1. **Immediately reduce API rate limits** from 1000/min to 100/min to prevent database overload 202 | 2. **Disable expensive features** (recommendations, analytics) to save resources 203 | 3. **Increase database connection pools** for critical services 204 | 4. **Enable circuit breakers** with different thresholds per region 205 | 206 | ### Why AWS Parameter Store + CI/CD Failed 207 | 208 | **Problem 1: Deployment Bottleneck** 209 | 210 | ```bash 211 | # What they tried: 212 | aws ssm put-parameter --name "/prod/api-rate-limit" --value "100" --overwrite 213 | # Result: 80+ services needed restarts to pick up new values 214 | # Time to deploy: 45 minutes (too slow for emergency) 215 | ``` 216 | 217 | **Problem 2: No Atomic Updates** 218 | 219 | ```bash 220 | # Needed to update 12 related parameters atomically: 221 | /prod/api/rate-limit: 100 222 | /prod/api/burst-limit: 20 223 | /prod/db/pool-size: 50 224 | /prod/features/recommendations: false 225 | # Some services got partial updates, causing inconsistent behavior 226 | ``` 227 | 228 | **Problem 3: No Validation** 229 | 230 | ```bash 231 | # Ops engineer accidentally set: 232 | aws ssm put-parameter --name "/prod/db/pool-size" --value "many" 233 | # 15 services crashed with "invalid integer" errors 234 | ``` 235 | 236 | **Problem 4: Team Conflicts** 237 | 238 | ```bash 239 | # Frontend team overwrote backend team's circuit breaker settings: 240 | aws ssm put-parameter --name "/prod/circuit-breaker-timeout" --value "30" 241 | # Backend expected milliseconds (30000), got seconds (30) 242 | # All API calls timed out 243 | ``` 244 | 245 | ### How HatiConfig Would Have Solved This 246 | 247 | **1. Instant Updates Without Deployments** 248 | 249 | ```ruby 250 | # Update rate limits instantly across all services 251 | config_server.update_config({ 252 | api: { rate_limit: 100, burst_limit: 20 }, 253 | database: { pool_size: 50 }, 254 | features: { recommendations: false } 255 | }) 256 | # All services pick up changes within 60 seconds via background refresh 257 | ``` 258 | 259 | **2. Atomic Configuration Updates** 260 | 261 | ```ruby 262 | # All related configs update together or not at all 263 | settings.configure :emergency_mode do 264 | config :api_rate_limit, value: 100, type: :integer 265 | config :db_pool_size, value: 50, type: :integer 266 | config :features_enabled, value: false, type: :boolean 267 | end 268 | # No partial updates, no inconsistent state 269 | ``` 270 | 271 | **3. Type Validation Prevents Crashes** 272 | 273 | ```ruby 274 | # This would fail immediately with clear error: 275 | settings.config :db_pool_size, value: "many", type: :integer 276 | # => HatiConfig::SettingTypeError: Expected Integer, got String "many" 277 | ``` 278 | 279 | **4. Team Namespaces Prevent Conflicts** 280 | 281 | ```ruby 282 | # Each team has isolated config space: 283 | MyApp.frontend.settings.circuit_breaker_timeout # => 30 (seconds) 284 | MyApp.backend.settings.circuit_breaker_timeout # => 30000 (milliseconds) 285 | # No accidental overwrites 286 | ``` 287 | 288 | ### The Real Cost of Simple Solutions 289 | 290 | **AWS Parameter Store approach:** 291 | 292 | - **Outage duration**: 2.5 hours (deployment + rollback time) 293 | - **Revenue loss**: $850,000 (estimated) 294 | - **Engineering time**: 40 person-hours for emergency response 295 | - **Customer trust**: Significant damage to brand 296 | 297 | **HatiConfig approach (estimated):** 298 | 299 | - **Outage duration**: 15 minutes (config updates + service stabilization) 300 | - **Revenue loss**: $50,000 301 | - **Engineering time**: 5 person-hours 302 | - **Customer trust**: Minimal impact 303 | 304 | ### When AWS Tools Work Fine 305 | 306 | **Small Scale Example:** 307 | 308 | ```bash 309 | # For a simple Rails app with 1-3 services: 310 | export DATABASE_URL="postgres://..." 311 | export REDIS_URL="redis://..." 312 | export SECRET_KEY_BASE="abc123..." 313 | 314 | # AWS Parameter Store works great: 315 | aws ssm get-parameters --names "/myapp/database-url" "/myapp/redis-url" 316 | ``` 317 | 318 | This works because: 319 | 320 | - Few parameters to manage 321 | - Changes are infrequent 322 | - Single team owns all configs 323 | - Deployment downtime is acceptable 324 | - No complex relationships between configs 325 | 326 | ### The Complexity Threshold 327 | 328 | **Use environment variables when:** 329 | 330 | - < 20 configuration values 331 | - Single team/service 332 | - Changes monthly or less 333 | - No complex validation needs 334 | - Deployment downtime OK 335 | 336 | **Use HatiConfig when:** 337 | 338 | - 100+ configuration values 339 | - Multiple teams/services 340 | - Changes daily or more 341 | - Complex validation/relationships 342 | - Zero-downtime config changes required 343 | 344 | ## Why CI/CD and AWS Tools Have Limits 345 | 346 | ### CI/CD Pipeline Limitations 347 | 348 | ```yaml 349 | # GitHub Actions config deployment 350 | - name: Deploy configs 351 | run: | 352 | aws ssm put-parameter --name "/prod/rate-limit" --value "${{ inputs.rate_limit }}" 353 | kubectl rollout restart deployment/api-service 354 | ``` 355 | 356 | **Problems:** 357 | 358 | - **15-30 minute deployment time** for config changes 359 | - **All-or-nothing**: Can't partially deploy config changes 360 | - **No rollback**: If config is wrong, need another full deployment 361 | - **Approval bottlenecks**: Config changes need same approval as code changes 362 | - **No emergency bypass**: Critical config changes still wait for CI/CD 363 | 364 | ### AWS Parameter Store Limitations 365 | 366 | ```bash 367 | # AWS Parameter Store has these limits: 368 | aws ssm get-parameters --names param1 param2 param3 # Max 10 parameters per call 369 | aws ssm get-parameters-by-path --path "/myapp/" # Max 10MB total response 370 | ``` 371 | 372 | **Problems:** 373 | 374 | - **API rate limits**: 1000 TPS standard, 10,000 TPS with higher pricing 375 | - **Size limits**: 4KB per parameter, 10MB per API response 376 | - **No atomic updates**: Can't update related parameters together 377 | - **No type validation**: Everything is a string 378 | - **No inheritance**: Can't have environment-specific overrides 379 | - **Expensive**: $0.05 per 10,000 requests + storage costs 380 | 381 | ### AWS Secrets Manager Limitations 382 | 383 | ```bash 384 | # Secrets Manager costs add up quickly: 385 | # $0.40/month per secret + $0.05 per 10,000 API calls 386 | # For 500 secrets: $200/month + API costs 387 | ``` 388 | 389 | **Problems:** 390 | 391 | - **High cost**: 10x more expensive than Parameter Store 392 | - **Slow retrieval**: 100-300ms per secret lookup 393 | - **No caching**: Need to implement your own caching layer 394 | - **JSON only**: Limited data structure support 395 | - **No configuration inheritance**: Each environment needs separate secrets 396 | 397 | ### Kubernetes ConfigMaps/Secrets Limitations 398 | 399 | ```yaml 400 | # ConfigMaps need pod restarts for updates 401 | apiVersion: v1 402 | kind: ConfigMap 403 | data: 404 | rate-limit: "1000" # Always strings, no validation 405 | ``` 406 | 407 | **Problems:** 408 | 409 | - **Pod restarts required**: Config changes need rolling updates 410 | - **No validation**: All values are strings 411 | - **Size limits**: 1MB per ConfigMap 412 | - **No encryption**: ConfigMaps are base64 encoded, not encrypted 413 | - **Cluster-bound**: Can't share configs across clusters/regions 414 | 415 | ### The Hidden Costs of "Simple" Solutions 416 | 417 | **Real example from a Series B startup (50 engineers):** 418 | 419 | **Before HatiConfig (AWS Parameter Store + CI/CD):** 420 | 421 | - **Config change time**: 25 minutes average 422 | - **Failed deployments**: 15% due to config errors 423 | - **Engineering time**: 8 hours/week on config management 424 | - **Outages**: 2 per quarter from config issues 425 | - **AWS costs**: $400/month (Parameter Store + Secrets Manager) 426 | 427 | **After HatiConfig:** 428 | 429 | - **Config change time**: 2 minutes average 430 | - **Failed deployments**: 2% due to config errors 431 | - **Engineering time**: 1 hour/week on config management 432 | - **Outages**: 0 per quarter from config issues 433 | - **Total costs**: $200/month (hosting + development time) 434 | 435 | **ROI calculation:** 436 | 437 | - **Time saved**: 7 hours/week × 50 engineers × $100/hour = $35,000/week 438 | - **Reduced outages**: 2 outages × $50,000 average cost = $100,000/quarter 439 | - **Annual benefit**: $1.9M+ in time savings and reduced downtime 440 | -------------------------------------------------------------------------------- /lib/hati_config/setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | require 'json' 5 | 6 | # HatiConfig module provides functionality for managing HatiConfig features. 7 | module HatiConfig 8 | # rubocop:disable Metrics/ClassLength 9 | 10 | # Setting class provides a configuration tree structure for managing settings. 11 | # 12 | # This class allows for dynamic configuration management, enabling 13 | # the loading of settings from hashes, YAML, or JSON formats. 14 | # 15 | # @example Basic usage 16 | # settings = Setting.new do 17 | # config(:key1, value: "example") 18 | # config(:key2, type: :int) 19 | # end 20 | # 21 | class Setting 22 | extend HatiConfig::Environment 23 | include HatiConfig::Environment 24 | extend HatiConfig::Schema 25 | extend HatiConfig::Cache 26 | extend HatiConfig::Encryption 27 | 28 | # Dynamically define methods for each type in TypeMap. 29 | # 30 | # @!method int(value) 31 | # Sets an integer configuration value. 32 | # @param value [Integer] The integer value to set. 33 | # 34 | # @!method string(value) 35 | # Sets a string configuration value. 36 | # @param value [String] The string value to set. 37 | # 38 | # ... (other type methods) 39 | HatiConfig::TypeMap.list_types.each do |type| 40 | define_method(type.downcase) do |stng, lock = nil| 41 | params = { type: type } 42 | params[:lock] = lock if lock.nil? 43 | 44 | config(stng, **params) 45 | end 46 | end 47 | 48 | # Initializes a new Setting instance. 49 | # 50 | # @yield [self] Configures the instance upon creation if a block is given. 51 | def initialize(&block) 52 | @config_tree = {} 53 | @schema = {} 54 | @immutable_schema = {} 55 | @encrypted_tree = {} 56 | 57 | if self.class.encryption_config.key_provider 58 | self.class.encryption do 59 | key_provider :env 60 | end 61 | end 62 | 63 | instance_eval(&block) if block_given? 64 | end 65 | 66 | # Loads configuration from a hash with an optional schema. 67 | # 68 | # @param data [Hash] The hash containing configuration data. 69 | # @param schema [Hash] Optional schema for type validation. 70 | # @raise [NoMethodError] If a method corresponding to a key is not defined. 71 | # @raise [SettingTypeError] If a value doesn't match the specified type in the schema. 72 | # 73 | # @example Loading from a hash with type validation 74 | # settings.load_from_hash({ name: "admin", max_connections: 10 }, schema: { name: :str, max_connections: :int }) 75 | def load_from_hash(data, schema: {}, lock_schema: {}, encrypted_fields: {}) 76 | data.each do |key, value| 77 | key = key.to_sym 78 | type = schema[key] if schema 79 | lock = lock_schema[key] if lock_schema 80 | encrypted = encrypted_fields[key] if encrypted_fields 81 | 82 | if value.is_a?(Hash) 83 | configure(key) do 84 | load_from_hash(value, 85 | schema: schema.is_a?(Hash) ? schema[key] : {}, 86 | lock_schema: lock_schema.is_a?(Hash) ? lock_schema[key] : {}, 87 | encrypted_fields: encrypted_fields.is_a?(Hash) ? encrypted_fields[key] : {}) 88 | end 89 | elsif value.is_a?(Setting) 90 | configure(key) do 91 | load_from_hash(value.to_h, schema: schema[key], lock_schema: lock_schema[key], 92 | encrypted_fields: encrypted_fields[key]) 93 | end 94 | else 95 | config(key => value, type: type, lock: lock, encrypted: encrypted) 96 | end 97 | end 98 | end 99 | 100 | # Configures a node of the configuration tree. 101 | # 102 | # @param node [Symbol, String] The name of the config node key. 103 | # @yield [Setting] A block that configures the new node. 104 | # 105 | # @example Configuring a new node 106 | # settings.configure(:database) do 107 | # config(:host, value: "localhost") 108 | # config(:port, value: 5432) 109 | # end 110 | def configure(node, &block) 111 | if config_tree[node] 112 | config_tree[node].instance_eval(&block) 113 | else 114 | create_new_node(node, &block) 115 | end 116 | end 117 | 118 | # Configures a setting with a given name and type. 119 | # 120 | # @param setting [Symbol, Hash, nil] The name of the setting or a hash of settings. 121 | # @param type [Symbol, nil] The expected type of the setting. 122 | # @param opt [Hash] Additional options for configuration. 123 | # @return [self] The current instance for method chaining. 124 | # @raise [SettingTypeError] If the value does not match the expected type. 125 | # 126 | # @example Configuring a setting 127 | # settings.config(max_connections: 10, type: :int) 128 | def config(setting = nil, type: nil, lock: nil, encrypted: false, **opt) 129 | return self if !setting && opt.empty? 130 | 131 | # If setting is a symbol/string and we have keyword options, merge them 132 | if (setting.is_a?(Symbol) || setting.is_a?(String)) && !opt.empty? 133 | raw_stngs = opt.merge(setting => opt[:value]) 134 | raw_stngs.delete(:value) 135 | else 136 | raw_stngs = setting || opt 137 | end 138 | stngs = extract_setting_info(raw_stngs) 139 | 140 | stng_lock = determine_lock(stngs, lock) 141 | stng_type = determine_type(stngs, type) 142 | stng_encrypted = determine_encrypted(stngs, encrypted) 143 | 144 | if stng_encrypted 145 | value = stngs[:value] 146 | if value.nil? && config_tree[stngs[:name]] 147 | value = config_tree[stngs[:name]] 148 | value = self.class.encryption_config.decrypt(value) if @encrypted_tree[stngs[:name]] 149 | end 150 | 151 | if value.is_a?(HatiConfig::Setting) 152 | # Handle nested settings 153 | value.instance_eval(&block) if block_given? 154 | elsif !value.nil? 155 | # If we're setting a new value or updating an existing one 156 | raise SettingTypeError.new('string (encrypted values must be strings)', value) unless value.is_a?(String) 157 | 158 | stngs[:value] = self.class.encryption_config.encrypt(value) 159 | @encrypted_tree[stngs[:name]] = true 160 | # If we're just marking an existing value as encrypted 161 | elsif config_tree[stngs[:name]] 162 | value = config_tree[stngs[:name]] 163 | raise SettingTypeError.new('string (encrypted values must be strings)', value) unless value.is_a?(String) 164 | 165 | stngs[:value] = self.class.encryption_config.encrypt(value) 166 | @encrypted_tree[stngs[:name]] = true 167 | end 168 | end 169 | 170 | validate_and_set_configuration(stngs, stng_lock, stng_type, stng_encrypted) 171 | self 172 | end 173 | 174 | # Returns the type schema of the configuration. 175 | # 176 | # @return [Hash] A hash representing the type schema. 177 | # @example Retrieving the type schema 178 | # schema = settings.type_schema 179 | def type_schema 180 | {}.tap do |hsh| 181 | config_tree.each do |k, v| 182 | v.is_a?(HatiConfig::Setting) ? (hsh[k] = v.type_schema) : hsh.merge!(schema) 183 | end 184 | end 185 | end 186 | 187 | def lock_schema 188 | {}.tap do |hsh| 189 | config_tree.each do |k, v| 190 | v.is_a?(HatiConfig::Setting) ? (hsh[k] = v.lock_schema) : hsh.merge!(immutable_schema) 191 | end 192 | end 193 | end 194 | 195 | # Converts the configuration tree into a hash. 196 | # 197 | # @return [Hash] The config tree as a hash. 198 | # @example Converting to hash 199 | # hash = settings.to_h 200 | def to_h 201 | {}.tap do |hsh| 202 | config_tree.each do |k, v| 203 | hsh[k] = if v.is_a?(HatiConfig::Setting) 204 | v.to_h 205 | else 206 | get_value(k) 207 | end 208 | end 209 | end 210 | end 211 | 212 | # Converts the configuration tree into YAML format. 213 | # 214 | # @param dump [String, nil] Optional file path to dump the YAML. 215 | # @return [String, nil] The YAML string or nil if dumped to a file. 216 | # @example Converting to YAML 217 | # yaml_string = settings.to_yaml 218 | # settings.to_yaml(dump: "config.yml") # Dumps to a file 219 | def to_yaml(dump: nil) 220 | yaml = to_h.to_yaml 221 | dump ? File.write(dump, yaml) : yaml 222 | end 223 | 224 | # Converts the configuration tree into JSON format. 225 | # 226 | # @return [String] The JSON representation of the configuration tree. 227 | # @example Converting to JSON 228 | # json_string = settings.to_json 229 | def to_json(*_args) 230 | to_h.to_json 231 | end 232 | 233 | # Provides hash-like access to configuration values 234 | # 235 | # @param key [Symbol, String] The key to access 236 | # @return [Object] The value associated with the key 237 | def [](key) 238 | key = key.to_sym if key.is_a?(String) 239 | return get_value(key) if config_tree.key?(key) 240 | 241 | raise NoMethodError, "undefined method `[]' with key #{key} for #{self.class}" 242 | end 243 | 244 | # Sets a configuration value using hash-like syntax 245 | # 246 | # @param key [Symbol, String] The key to set 247 | # @param value [Object] The value to set 248 | def []=(key, value) 249 | key = key.to_sym if key.is_a?(String) 250 | config(key => value) 251 | end 252 | 253 | protected 254 | 255 | # @return [Hash] The schema of configuration types. 256 | attr_reader :schema, :immutable_schema 257 | 258 | private 259 | 260 | # @return [Hash] The tree structure of configuration settings. 261 | attr_reader :config_tree 262 | 263 | # Creates a new node in the configuration tree. 264 | # 265 | # @param node [Symbol] The name of the node to create. 266 | # @yield [Setting] A block to configure the new setting. 267 | # @return [Setting] The newly created setting node. 268 | def create_new_node(node, &block) 269 | new_node = HatiConfig::Setting.new 270 | if self.class.encryption_config.key_provider 271 | new_node.class.encryption do 272 | key_provider :env 273 | end 274 | end 275 | new_node.instance_eval(&block) if block_given? 276 | config_tree[node] = new_node 277 | define_node_methods(node) 278 | new_node 279 | end 280 | 281 | # Defines singleton methods for the given node. 282 | # 283 | # @param node [Symbol] The name of the node to define methods for. 284 | def define_node_methods(node) 285 | define_singleton_method(node) do |*_args, &node_block| 286 | if node_block 287 | config_tree[node].instance_eval(&node_block) 288 | else 289 | config_tree[node] 290 | end 291 | end 292 | end 293 | 294 | # Extracts the setting information from the provided input. 295 | # 296 | # @param stngs [Symbol, Hash] The setting name or a hash containing the setting name and value. 297 | # @return [Hash] A hash containing the setting name and its corresponding value. 298 | def extract_setting_info(stngs) 299 | val = nil 300 | lock = nil 301 | encrypted = nil 302 | 303 | if stngs.is_a?(Symbol) 304 | name = stngs 305 | elsif stngs.is_a?(Hash) 306 | lock = stngs.delete(:lock) 307 | encrypted = stngs.delete(:encrypted) 308 | name, val = stngs.to_a.first 309 | end 310 | 311 | { name: name, value: val, lock: lock, encrypted: encrypted } 312 | end 313 | 314 | def handle_value(key, value, type, lock, encrypted = false) 315 | validate_mutable!(key, lock) if lock 316 | validate_setting!(value, type) if type 317 | 318 | config(key => value, type: type, lock: lock, encrypted: encrypted) 319 | self 320 | end 321 | 322 | def get_value(key) 323 | value = config_tree[key] 324 | return value if value.is_a?(HatiConfig::Setting) 325 | return self.class.encryption_config.decrypt(value) if @encrypted_tree[key] 326 | 327 | value 328 | end 329 | 330 | # Validates the setting value against the expected type. 331 | # 332 | # @param stng_val [Object] The value of the setting to validate. 333 | # @param stng_type [Symbol] The expected type of the setting. 334 | # @raise [SettingTypeError] If the setting value does not match the expected type. 335 | def validate_setting!(stng_val, stng_type) 336 | is_valid = HatiConfig::TypeChecker.call(stng_val, type: stng_type) 337 | raise HatiConfig::SettingTypeError.new(stng_type, stng_val) unless !stng_val || is_valid 338 | end 339 | 340 | def validate_mutable!(name, stng_lock) 341 | raise "<#{name}> setting is immutable" if stng_lock 342 | end 343 | 344 | # Sets the configuration for a given setting name, value, and type. 345 | # 346 | # @param stng_name [Symbol] The name of the setting to configure. 347 | # @param stng_val [Object] The value to assign to the setting. 348 | # @param stng_type [Symbol] The type of the setting. 349 | def set_configuration(stng_name:, stng_val:, stng_type:, stng_lock:) 350 | schema[stng_name] = stng_type 351 | config_tree[stng_name] = stng_val 352 | immutable_schema[stng_name] = stng_lock 353 | 354 | return if respond_to?(stng_name) 355 | 356 | define_singleton_method(stng_name) do |value = nil, type: nil, lock: nil, encrypted: false| 357 | return get_value(stng_name) unless value || encrypted 358 | 359 | if encrypted && !value.nil? && !value.is_a?(String) 360 | raise SettingTypeError.new('string (encrypted values must be strings)', value) 361 | end 362 | 363 | config(**{ stng_name => value, type: type, lock: lock, encrypted: encrypted }) 364 | end 365 | end 366 | 367 | def determine_lock(stngs, lock) 368 | lock.nil? ? stngs[:lock] : lock 369 | end 370 | 371 | def determine_type(stngs, type) 372 | type || schema[stngs[:name]] || :any 373 | end 374 | 375 | def determine_encrypted(stngs, encrypted) 376 | encrypted.nil? ? stngs[:encrypted] : encrypted 377 | end 378 | 379 | def validate_and_set_configuration(stngs, stng_lock, stng_type, stng_encrypted) 380 | validate_mutable!(stngs[:name], stng_lock) if stngs[:value] && config_tree[stngs[:name]] && !stng_lock 381 | validate_setting!(stngs[:value], stng_type) 382 | 383 | set_configuration(stng_name: stngs[:name], stng_val: stngs[:value], stng_type: stng_type, stng_lock: stng_lock) 384 | @encrypted_tree[stngs[:name]] = stng_encrypted if stng_encrypted 385 | self 386 | end 387 | end 388 | # rubocop:enable Metrics/ClassLength 389 | end 390 | -------------------------------------------------------------------------------- /README_NEW.md: -------------------------------------------------------------------------------- 1 | # HatiConfig 2 | 3 | A Ruby approach to configuration management, inspired by real-world challenges in distributed systems. This gem explores practical solutions for teams dealing with configuration complexity at scale. 4 | 5 | ## Table of Contents 6 | 7 | - [Overview & Configuration Patterns](OVERVIEW.md) 8 | - [Installation](#installation) 9 | - [Basic Usage](#basic-usage) 10 | - [Configuration Container Usage](#configuration-container-usage) 11 | - [Define with DSL Syntax](#define-with-dsl-syntax) 12 | - Distributed Features 13 | - [Remote Configuration](#remote-configuration) 14 | - [Environment Management](#environment-management) 15 | - [Team Isolation](#team-isolation) 16 | - [Schema Versioning](#schema-versioning) 17 | - [Caching and Refresh](#caching-and-refresh) 18 | - [Encryption](#encryption) 19 | - Typing 20 | - [Configure Type Validation](#configure-type-validation) 21 | - [Define Configuration Type Validation](#define-configuration-type-validation) 22 | - [Type Schema](#type_schema) 23 | - [Built-in Types](#built-in-types) 24 | - Import/Export: 25 | - [Loading Configurations](#loading-configuration-data) 26 | - [Loading from Remote Sources](#loading-from-remote-sources) 27 | - [Loading from a JSON String](#loading-from-a-json-string) 28 | - [Loading from a YAML File](#loading-from-a-yaml-file) 29 | - [Exporting Configurations](#exporting-configuration-data) 30 | - [to_h](#to_h) 31 | - [to_json](#to_json) 32 | - [to_yaml](#to_yaml) 33 | - Security 34 | - [Encryption](#encryption) 35 | - [Encryption Key Providers](#encryption-key-providers) 36 | - [Security Features](#security-features) 37 | - OSS 38 | - [Development](#development) 39 | - [Contributing](#contributing) 40 | - [License](#license) 41 | - [Code of Conduct](#code-of-conduct) 42 | 43 | --- 44 | 45 | ## Features 46 | 47 | - **Simple Configuration Management**: Easily define, set, and retrieve configuration options. 48 | - **Type Validation**: Ensure configurations are correct with built-in type validation. 49 | - **Multiple Formats**: Import and export configurations in JSON, YAML, and Hash formats. 50 | - **Nested Configurations**: Support for infinite nested configurations for complex applications. 51 | - **Classy Access**: Access configurations in a 'classy' manner for better organization and readability. 52 | - **Built-in Types**: Utilize various built-in types including basic types, data structures, numeric types, and time types. 53 | - **Extensible**: Easily extendable to accommodate custom configuration needs. 54 | 55 | ## Recent Updates 56 | 57 | ### Version 1.1.0 (Latest) 58 | 59 | - **Fixed**: Encryption functionality now works correctly with the `config(key, value: "secret", encrypted: true)` syntax 60 | - **Enhanced**: Improved encryption handling for both inline and hash-based configuration syntax 61 | - **Improved**: Better error handling and type validation for encrypted values 62 | - **Updated**: Comprehensive encryption documentation with practical examples 63 | 64 | --- 65 | 66 | ## Installation 67 | 68 | Install the gem and add to the application's Gemfile by executing: 69 | 70 | ```bash 71 | bundle add hati-config 72 | ``` 73 | 74 | If bundler is not being used to manage dependencies, install the gem by executing: 75 | 76 | ```bash 77 | gem install hati-config 78 | ``` 79 | 80 | ## Basic Usage 81 | 82 | **Use Case**: You're building a Ruby application that needs clean, type-safe configuration management. You want to avoid scattered constants, magic strings, and configuration bugs that crash production. You need nested configs, type validation, and the ability to export configs for debugging. 83 | 84 | **Why existing tools fall short**: 85 | 86 | - Plain Ruby constants are global and can't be nested cleanly 87 | - YAML files lack type safety and runtime validation 88 | - Environment variables become unwieldy with complex nested configs 89 | - No built-in export/import capabilities for debugging and testing 90 | 91 | **HatiConfig solution**: Clean DSL for defining configs with automatic type validation, nested namespaces, and built-in serialization. 92 | 93 | ```ruby 94 | require 'hati_config' 95 | 96 | module MyApp 97 | extend HatiConfig 98 | end 99 | 100 | MyApp.configure :settings do 101 | config option: 42 102 | config.int typed_opt_one: 42 103 | config typed_opt_two: 4.2, type: :float 104 | end 105 | 106 | MyApp.settings.option # => 42 107 | MyApp.settings.typed_opt_one # => 42 108 | MyApp.settings.typed_opt_two # => 4.2 109 | ``` 110 | 111 | ### With Encryption 112 | 113 | ```ruby 114 | require 'hati_config' 115 | 116 | # Set up encryption key 117 | ENV['HATI_CONFIG_ENCRYPTION_KEY'] = '0' * 32 # 256-bit key 118 | 119 | # Create a setting instance with encryption 120 | settings = HatiConfig::Setting.new 121 | 122 | # Configure encryption 123 | settings.class.encryption do 124 | key_provider :env 125 | end 126 | 127 | # Configure encrypted and plain values 128 | settings.config :api_key, value: "secret-key", encrypted: true 129 | settings.config :public_url, value: "https://api.example.com" 130 | 131 | settings[:api_key] # => "secret-key" (automatically decrypted) 132 | settings[:public_url] # => "https://api.example.com" (plain text) 133 | ``` 134 | 135 | ### Basic Syntax 136 | 137 | ```ruby 138 | MyApp.configure :settings do 139 | config option: 42 140 | end 141 | ``` 142 | 143 | ## Namespacing 144 | 145 | ```ruby 146 | MyApp.configure :app do 147 | configure :lvl_one do 148 | config opt: 100 149 | configure :lvl_two do 150 | config opt: 200 151 | configure :lvl_three do 152 | config opt: 300 153 | configure :lvl_four do 154 | config opt: 400 155 | configure :lvl_five do 156 | config opt: 500 157 | # NOTE: as deep as you want 158 | end 159 | end 160 | end 161 | end 162 | end 163 | end 164 | 165 | MyApp.app.lvl_one.opt # => 100 166 | MyApp.app.lvl_one.lvl_two.opt # => 200 167 | MyApp.app.lvl_one.lvl_two.lvl_three.opt # => 300 168 | MyApp.app.lvl_one.lvl_two.lvl_three.lvl_four.opt # => 400 169 | MyApp.app.lvl_one.lvl_two.lvl_three.lvl_four.lvl_five.opt # => 500 170 | ``` 171 | 172 | ### Configure Type Validation 173 | 174 | ```ruby 175 | MyApp.configure :settings do 176 | config custom_typed_opt_one: '42', type: :float 177 | end 178 | # => HatiConfig::SettingTypeError 179 | ``` 180 | 181 | ## Distributed Features 182 | 183 | ### Remote Configuration 184 | 185 | **Use Case**: You're running a microservices architecture with 50+ services across multiple regions. Each service needs to know database endpoints, API keys, and feature flags that change frequently. Traditional config files mean redeploying every service when anything changes, causing downtime and deployment bottlenecks. 186 | 187 | **Why existing tools fall short**: 188 | 189 | - Environment variables become unmanageable with hundreds of configs 190 | - Config files require application restarts and deployments 191 | - Tools like Consul require additional infrastructure and learning curve 192 | - Most solutions don't handle automatic refresh or fallback gracefully 193 | 194 | **HatiConfig solution**: Load configurations from HTTP endpoints, S3, or Redis with automatic refresh, caching, and fallback. Update configs without touching code or deployments. 195 | 196 | HatiConfig supports loading configurations from various remote sources: 197 | 198 | ```ruby 199 | require 'hati_config' 200 | 201 | module MyApp 202 | extend HatiConfig 203 | 204 | # Load from HTTP endpoint 205 | configure :api_settings, http: { 206 | url: 'https://config-server/api-config.json', 207 | headers: { 'Authorization' => 'Bearer token' }, 208 | refresh_interval: 300 # refresh every 5 minutes 209 | } 210 | 211 | # Load from S3 212 | configure :database_settings, s3: { 213 | bucket: 'my-configs', 214 | key: 'database.yml', 215 | region: 'us-west-2', 216 | refresh_interval: 600 217 | } 218 | 219 | # Load from Redis 220 | configure :feature_flags, redis: { 221 | host: 'redis.example.com', 222 | key: 'feature_flags', 223 | refresh_interval: 60 224 | } 225 | end 226 | ``` 227 | 228 | ### Environment Management 229 | 230 | **Use Case**: Your application runs across development, staging, production, and multiple regional production environments. Each environment needs different database URLs, API endpoints, timeout values, and feature flags. Developers accidentally use production configs in development, or staging configs leak into production, causing outages. 231 | 232 | **Why existing tools fall short**: 233 | 234 | - Rails environments are limited and don't handle complex multi-region setups 235 | - Multiple config files lead to duplication and inconsistency 236 | - No validation that the right configs are loaded in the right environment 237 | - Switching between environments requires manual file changes or complex deployment scripts 238 | 239 | **HatiConfig solution**: Define base configurations with environment-specific overrides. Built-in environment detection with validation ensures the right configs are always loaded. 240 | 241 | Easily manage configurations across different environments: 242 | 243 | ```ruby 244 | module MyApp 245 | extend HatiConfig 246 | 247 | configure :settings do 248 | # Base configuration 249 | config :timeout, default: 30 250 | config :retries, default: 3 251 | 252 | # Environment-specific overrides 253 | environment :development do 254 | config :api_url, value: 'http://localhost:3000' 255 | config :debug, value: true 256 | end 257 | 258 | environment :staging do 259 | config :api_url, value: 'https://staging-api.example.com' 260 | config :timeout, value: 60 261 | end 262 | 263 | environment :production do 264 | config :api_url, value: 'https://api.example.com' 265 | config :timeout, value: 15 266 | config :retries, value: 5 267 | end 268 | end 269 | end 270 | ``` 271 | 272 | ### Team Isolation 273 | 274 | **Use Case**: You work at a large tech company with 10+ engineering teams (Frontend, Backend, Mobile, DevOps, ML, etc.). Each team has their own configurations, but they all deploy to shared infrastructure. Teams accidentally override each other's configs, causing mysterious production issues that take hours to debug. 275 | 276 | **Why existing tools fall short**: 277 | 278 | - Shared config files create merge conflicts and accidental overwrites 279 | - Namespace collisions are common (multiple teams using "database_url") 280 | - No clear ownership or boundaries for configuration sections 281 | - Changes by one team can break another team's services 282 | 283 | **HatiConfig solution**: Create isolated namespaces for each team. Teams can safely manage their own configs without affecting others, while still sharing common infrastructure settings. 284 | 285 | Prevent configuration conflicts between teams: 286 | 287 | ```ruby 288 | module MyApp 289 | extend HatiConfig 290 | 291 | # Team-specific configuration namespace 292 | team :frontend do 293 | configure :settings do 294 | config :api_endpoint, value: '/api/v1' 295 | config :cache_ttl, value: 300 296 | end 297 | end 298 | 299 | team :backend do 300 | configure :settings do 301 | config :database_pool, value: 5 302 | config :worker_threads, value: 10 303 | end 304 | end 305 | 306 | team :mobile do 307 | configure :settings do 308 | config :push_notifications, value: true 309 | config :offline_mode, value: true 310 | end 311 | end 312 | end 313 | 314 | # Access team configurations 315 | MyApp.frontend.settings.api_endpoint # => '/api/v1' 316 | MyApp.backend.settings.database_pool # => 5 317 | MyApp.mobile.settings.offline_mode # => true 318 | ``` 319 | 320 | ### Schema Versioning 321 | 322 | **Use Case**: Your application has evolved over 2 years. The original config had simple database settings, but now includes complex microservice endpoints, ML model parameters, and feature flags. Old configs are incompatible with new code, but you need to support gradual rollouts and rollbacks without breaking existing deployments. 323 | 324 | **Why existing tools fall short**: 325 | 326 | - No versioning means breaking changes crash old application versions 327 | - Manual migration scripts are error-prone and forgotten 328 | - No way to validate that configs match the expected schema 329 | - Rolling back code requires manually reverting config changes 330 | 331 | **HatiConfig solution**: Version your configuration schemas with automatic migrations. Validate configs against expected schemas and handle version mismatches gracefully. 332 | 333 | Track and validate configuration schema changes: 334 | 335 | ```ruby 336 | module MyApp 337 | extend HatiConfig 338 | 339 | configure :settings, version: '2.0' do 340 | # Schema definition with version constraints 341 | schema do 342 | required :database_url, type: :string, since: '1.0' 343 | required :pool_size, type: :integer, since: '1.0' 344 | optional :replica_urls, type: [:string], since: '2.0' 345 | deprecated :old_setting, since: '2.0', remove_in: '3.0' 346 | end 347 | 348 | # Migrations for automatic updates 349 | migration '1.0' => '2.0' do |config| 350 | config.replica_urls = [config.delete(:backup_url)].compact 351 | end 352 | end 353 | end 354 | ``` 355 | 356 | ### Caching and Refresh 357 | 358 | **Use Case**: Your application makes 1000+ requests per second and needs to check feature flags and rate limits on each request. Fetching configs from remote sources every time would crush your config server and add 50ms latency to every request. But configs can change and you need updates within 1 minute for critical flags. 359 | 360 | **Why existing tools fall short**: 361 | 362 | - No caching means every request hits the config server 363 | - Simple TTL caching means stale data during config server outages 364 | - No intelligent refresh strategies lead to thundering herd problems 365 | - Manual cache invalidation is complex and error-prone 366 | 367 | **HatiConfig solution**: Intelligent caching with stale-while-revalidate, background refresh, exponential backoff, and jitter to prevent thundering herds. 368 | 369 | Configure caching behavior and automatic refresh: 370 | 371 | ```ruby 372 | module MyApp 373 | extend HatiConfig 374 | 375 | configure :settings do 376 | # Cache configuration 377 | cache do 378 | adapter :redis, url: 'redis://cache.example.com:6379/0' 379 | ttl 300 # 5 minutes 380 | stale_while_revalidate true 381 | end 382 | 383 | # Refresh strategy 384 | refresh do 385 | interval 60 # check every minute 386 | jitter 10 # add random delay (0-10 seconds) 387 | backoff do 388 | initial 1 389 | multiplier 2 390 | max 300 391 | end 392 | end 393 | end 394 | end 395 | ``` 396 | 397 | ### Encryption 398 | 399 | **Use Case**: Your application handles API keys, database passwords, OAuth secrets, and encryption keys that are worth millions if compromised. These secrets are scattered across config files, environment variables, and deployment scripts. A single leaked config file or compromised CI/CD pipeline exposes everything. Compliance requires encryption at rest and audit trails. 400 | 401 | **Why existing tools fall short**: 402 | 403 | - Environment variables are visible in process lists and logs 404 | - Config files with secrets get committed to Git accidentally 405 | - Kubernetes secrets are base64 encoded, not encrypted 406 | - External secret managers add complexity and network dependencies 407 | - No transparent encryption/decryption in application code 408 | 409 | **HatiConfig solution**: Automatic encryption of sensitive values with multiple key providers (env, files, AWS KMS). Values are encrypted at rest and decrypted transparently when accessed. 410 | 411 | Secure sensitive configuration values with built-in encryption support: 412 | 413 | ```ruby 414 | require 'hati_config' 415 | 416 | # Set the encryption key via environment variable 417 | ENV['HATI_CONFIG_ENCRYPTION_KEY'] = '0123456789abcdef' * 2 # 32-character key 418 | 419 | # Create a settings instance 420 | settings = HatiConfig::Setting.new 421 | 422 | # Configure encryption with environment variable key provider 423 | settings.class.encryption do 424 | key_provider :env # Uses HATI_CONFIG_ENCRYPTION_KEY environment variable 425 | algorithm 'aes' # AES encryption (default) 426 | key_size 256 # 256-bit keys (default) 427 | mode 'gcm' # GCM mode (default) 428 | end 429 | 430 | # Configure settings with encrypted and plain values 431 | settings.config :api_key, value: 'secret-api-key', encrypted: true 432 | settings.config :database_password, value: 'super-secret-password', encrypted: true 433 | 434 | # Regular unencrypted values 435 | settings.config :api_url, value: 'https://api.example.com' 436 | 437 | # Nested configurations with encryption 438 | settings.configure :database do 439 | config :host, value: 'db.example.com' 440 | config :password, value: 'db-secret', encrypted: true 441 | config :username, value: 'app_user' 442 | end 443 | 444 | # Access values - encrypted values are automatically decrypted 445 | settings[:api_key] # => 'secret-api-key' (decrypted) 446 | settings.database[:password] # => 'db-secret' (decrypted) 447 | settings[:api_url] # => 'https://api.example.com' (plain) 448 | ``` 449 | 450 | #### Encryption Key Providers 451 | 452 | The gem supports multiple key providers for encryption keys: 453 | 454 | ```ruby 455 | # Environment variable (default) 456 | settings.class.encryption do 457 | key_provider :env, env_var: 'MY_ENCRYPTION_KEY' # Custom env var name 458 | end 459 | 460 | # File-based key 461 | settings.class.encryption do 462 | key_provider :file, file_path: '/secure/path/to/key.txt' 463 | end 464 | 465 | # AWS KMS (requires aws-sdk-kms gem) 466 | settings.class.encryption do 467 | key_provider :aws_kms, key_id: 'alias/config-key', region: 'us-west-2' 468 | end 469 | ``` 470 | 471 | #### Security Features 472 | 473 | - **AES-256-GCM encryption**: Industry-standard encryption with authentication 474 | - **Automatic encryption/decryption**: Values are encrypted when stored and decrypted when accessed 475 | - **Type safety**: Only string values can be encrypted (enforced at runtime) 476 | - **Multiple key providers**: Support for environment variables, files, and AWS KMS 477 | - **Secure storage**: Encrypted values are stored as Base64-encoded strings 478 | 479 | ## Configuration Container Usage 480 | 481 | ```ruby 482 | require 'hati_config' 483 | 484 | module MyGem 485 | extend HatiConfig 486 | end 487 | ``` 488 | 489 | ### Declare configurations 490 | 491 | ```ruby 492 | MyGem.configure :settings do 493 | config :option 494 | config.int :typed_opt_one 495 | config :typed_opt_two, type: Integer 496 | # NOTE: declare nested namespace with configure 497 | configure :nested do 498 | config :option 499 | end 500 | end 501 | ``` 502 | 503 | ### Define configurations 504 | 505 | ```ruby 506 | MyGem.settings do 507 | config option: 1 508 | config typed_opt_one: 2 509 | config typed_opt_two: 3 510 | # NOTE: access namespace via <.dot_access> 511 | config.nested do 512 | config option: 4 513 | end 514 | end 515 | ``` 516 | 517 | ### Define with DSL Syntax 518 | 519 | ```ruby 520 | MyGem.settings do 521 | option 'one' 522 | typed_opt_one 1 523 | typed_opt_two 2 524 | # NOTE: access namespace via 525 | nested do 526 | option 'nested' 527 | end 528 | end 529 | ``` 530 | 531 | ### Get configurations 532 | 533 | ```ruby 534 | MyGem.settings.option # => 'one' 535 | MyGem.settings.typed_opt_one # => 1 536 | MyGem.settings.typed_opt_two # => 2 537 | MyGem.settings.nested.option # => 'nested' 538 | ``` 539 | 540 | ### Define Configuration Type Validation 541 | 542 | ```ruby 543 | MyGem.settings do 544 | config.typed_opt_two: '1' 545 | end 546 | # => HatiConfig::SettingTypeError 547 | 548 | MyGem.settings do 549 | typed_opt_two '1' 550 | end 551 | # => HatiConfig::SettingTypeError 552 | ``` 553 | 554 | ### Union 555 | 556 | ```ruby 557 | # List one of entries as built-in :symbols or classes 558 | 559 | MyApp.configure :settings do 560 | config transaction_fee: 42, type: [Integer, :float, BigDecimal] 561 | config vendor_code: 42, type: [String, :int] 562 | end 563 | ``` 564 | 565 | ### Custom 566 | 567 | ```ruby 568 | MyApp.configure :settings do 569 | config option: CustomClass.new, type: CustomClass 570 | end 571 | ``` 572 | 573 | ### Callable 574 | 575 | ```ruby 576 | acc_proc = Proc.new { |val| val.respond_to?(:accounts) } 577 | holder_lam = ->(name) { name.length > 5 } 578 | 579 | MyApp.configure :settings do 580 | config acc_data: User.new, type: acc_proc 581 | config holder_name: 'John Doe', type: holder_lam 582 | end 583 | ``` 584 | 585 | ## Loading Configuration Data 586 | 587 | **Use Case**: Your application needs to load configuration from existing YAML files, JSON APIs, or hash data from databases. You want to validate the loaded data against expected schemas and handle format errors gracefully. Different environments might use different config sources (files in development, APIs in production). 588 | 589 | **Why existing tools fall short**: 590 | 591 | - Manual YAML/JSON parsing is error-prone and lacks validation 592 | - No schema validation means runtime errors from bad config data 593 | - Mixing different config sources requires complex custom code 594 | - No unified interface for different data formats 595 | 596 | **HatiConfig solution**: Unified interface for loading from YAML, JSON, and Hash sources with optional schema validation and clear error handling. 597 | 598 | The `HatiConfig` module allows you to load configuration data from various sources, including YAML and JSON. Below are the details for each option. 599 | 600 | - `json` (String) 601 | - `yaml` (String) 602 | - `hash` (Hash) 603 | - `schema` (Hash) (Optional) See: [Type Schema](#type_schema) and [Built-in Types](#built-in-types) 604 | 605 | ### Loading from a JSON String 606 | 607 | You can load configuration data from a JSON string by passing the `json` option to the `configure` method. 608 | 609 | #### Parameters 610 | 611 | - `json` (String): A JSON string containing the configuration data. 612 | - `schema` (Hash) (Optional): A hash representing the type schema for the configuration data. 613 | 614 | #### Error Handling 615 | 616 | - If the JSON format is invalid, a `LoadDataError` will be raised with the message "Invalid JSON format". 617 | 618 | #### Example 1 619 | 620 | ```ruby 621 | MyGem.configure(:settings, json: '{"opt_one":1,"opt_two":2}').settings 622 | # => #1, :opt_two=>2}> 623 | ``` 624 | 625 | #### Example 2 626 | 627 | ```ruby 628 | MyGem.configure(:settings, json: '{"opt_one":1,"opt_two":2}', schema: { opt_one: :int, opt_two: :str }) 629 | # => HatiConfig::SettingTypeError: Expected: . Given: 2 which is class. 630 | ``` 631 | 632 | #### Example 3 633 | 634 | ```ruby 635 | MyGem.configure(:settings, json: '{"opt_one":1,"opt_two":2}', schema: { opt_one: :int, opt_two: :int }) 636 | 637 | MyGem.settings do 638 | opt_one 1 639 | opt_two "2" 640 | end 641 | # => HatiConfig::SettingTypeError: Expected: . Given: \"2\" which is class. 642 | ``` 643 | 644 | ### Loading from a YAML File 645 | 646 | You can also load configuration data from a YAML file by passing the `yaml` option to the `configure` method. 647 | 648 | #### Parameters 649 | 650 | - `yaml` (String): A file path to a YAML file containing the configuration data. 651 | - `schema` (Hash) (Optional): A hash representing the type schema for the configuration data. 652 | 653 | #### Error Handling 654 | 655 | - If the specified YAML file is not found, a `LoadDataError` will be raised with the message "YAML file not found". 656 | 657 | ##### YAML File 658 | 659 | ```yaml 660 | # settings.yml 661 | 662 | opt_one: 1 663 | opt_two: 2 664 | ``` 665 | 666 | #### Example 1 667 | 668 | ```ruby 669 | MyGem.configure :settings, yaml: 'settings.yml' 670 | # => #1, :opt_two=>2}> 671 | ``` 672 | 673 | #### Example 2 674 | 675 | ```ruby 676 | MyGem.configure :settings, yaml: 'settings.yml', schema: { opt_one: :int, opt_two: :str } 677 | # => HatiConfig::SettingTypeError: Expected: . Given: 2 which is class. 678 | ``` 679 | 680 | #### Example 3 681 | 682 | ```ruby 683 | MyGem.configure :settings, yaml: 'settings.yml', schema: { opt_one: :int, opt_two: :int } 684 | 685 | MyGem.settings do 686 | opt_one 1 687 | opt_two "2" 688 | end 689 | # => HatiConfig::SettingTypeError: Expected: . Given: \"2\" which is class. 690 | ``` 691 | 692 | ## Exporting Configuration Data 693 | 694 | You can dump the configuration data in various formats using the following methods: 695 | 696 | ### to_h 697 | 698 | ```ruby 699 | MyGem.configure :settings do 700 | config opt_one: 1 701 | config opt_two: 2 702 | end 703 | 704 | MyGem.settings.to_json # => '{"opt_one":1,"opt_two":2}' 705 | ``` 706 | 707 | ### to_json 708 | 709 | ```ruby 710 | MyGem.configure :settings do 711 | config opt_one: 1 712 | config opt_two: 2 713 | end 714 | 715 | MyGem.settings.to_json # => '{"opt_one":1,"opt_two":2}' 716 | ``` 717 | 718 | ### to_yaml 719 | 720 | ```ruby 721 | MyGem.configure :settings do 722 | config opt_one: 1 723 | config opt_two: 2 724 | end 725 | 726 | MyGem.settings.to_yaml # => "---\nopt_one: 1\nopt_two: 2\n" 727 | ``` 728 | 729 | ### type_schema 730 | 731 | ```ruby 732 | MyGem.configure :settings do 733 | config.int opt_one: 1 734 | config.str opt_two: "2" 735 | end 736 | 737 | MyGem.settings.type_schema # => {:opt_one=>:int, :opt_two=>:str} 738 | ``` 739 | 740 | ## Built-in Types Features 741 | 742 | **Use Case**: Your application crashes in production because someone set a timeout to "30 seconds" instead of 30, or a database pool size to "many" instead of 10. You need runtime type validation that catches these errors early and provides clear error messages. Different config values need different types (strings, integers, arrays, custom objects). 743 | 744 | **Why existing tools fall short**: 745 | 746 | - No runtime type checking means silent failures or crashes 747 | - Custom validation code is scattered throughout the application 748 | - Error messages are unclear ("expected Integer, got String") 749 | - No support for complex types like arrays of specific types or custom classes 750 | 751 | **HatiConfig solution**: Comprehensive type system with built-in types, composite types, custom validators, and clear error messages. 752 | 753 | ### Base Types 754 | 755 | ```ruby 756 | :int => Integer 757 | :str => String 758 | :sym => Symbol 759 | :null => NilClass 760 | :any => Object 761 | :true_class => TrueClass 762 | :false_class => FalseClass 763 | ``` 764 | 765 | ### Data Structures 766 | 767 | ```ruby 768 | :hash => Hash 769 | :array => Array 770 | ``` 771 | 772 | ### Numeric 773 | 774 | ```ruby 775 | :big_decimal => BigDecimal, 776 | :float => Float, 777 | :complex => Complex, 778 | :rational => Rational, 779 | ``` 780 | 781 | ### Time 782 | 783 | ```ruby 784 | :date => Date, 785 | :date_time => DateTime, 786 | :time => Time, 787 | ``` 788 | 789 | ### Composite 790 | 791 | ```ruby 792 | :bool => [TrueClass, FalseClass], 793 | :numeric => [Integer, Float, BigDecimal], 794 | :kernel_num => [Integer, Float, BigDecimal, Complex, Rational], 795 | :chrono => [Date, DateTime, Time] 796 | ``` 797 | 798 | ## Development 799 | 800 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 801 | 802 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release` to publish the `.gem` file to [rubygems.org](https://rubygems.org). 803 | 804 | ## Contributing 805 | 806 | Bug reports and feature requests are welcome. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct. 807 | 808 | ## License 809 | 810 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 811 | 812 | ## Code of Conduct 813 | 814 | Everyone interacting in the HatiConfig project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct. 815 | --------------------------------------------------------------------------------