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,42 @@
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
@@ -0,0 +1,284 @@
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
@@ -0,0 +1,140 @@
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
@@ -0,0 +1,244 @@
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