hati-config 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,54 @@
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: <int>. Given: \"string\" which is <String> 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: <unknown_type> 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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,213 @@
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