ibm_appconfiguration_ruby_sdk 0.1.0.pre.rc.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +76 -0
  4. data/CONTRIBUTING.md +9 -0
  5. data/LICENSE +201 -0
  6. data/README.md +474 -0
  7. data/Rakefile +8 -0
  8. data/examples/README.md +60 -0
  9. data/examples/app.rb +104 -0
  10. data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
  11. data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
  12. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
  13. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
  14. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
  15. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
  16. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
  17. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
  18. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
  19. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
  20. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
  21. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
  22. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
  23. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
  24. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
  25. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
  26. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
  27. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
  28. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
  29. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
  30. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
  31. data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
  32. data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
  33. data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
  34. data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
  35. data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
  36. data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
  37. metadata +209 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 IBM Corp. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative "../../../core/api_manager"
18
+ require_relative "../../../core/url_builder"
19
+ require_relative "../utils"
20
+ require_relative "../logger"
21
+ require_relative "../../models/feature"
22
+ require_relative "../../models/property"
23
+ require_relative "../../models/segment"
24
+ require_relative "../../configuration_handler"
25
+
26
+ # ConfigFetcher
27
+ #
28
+ # Handles fetching configurations from the App Configuration API.
29
+ # This class encapsulates all API call logic and response handling.
30
+ #
31
+ class ConfigFetcher
32
+ # Initialize the config fetcher
33
+ #
34
+ # @param collection_id [String] Collection ID for API request
35
+ # @param environment_id [String] Environment ID for API request
36
+ # @param logger [Logger] Optional logger instance
37
+ def initialize(collection_id:, environment_id:, logger: nil)
38
+ @collection_id = collection_id
39
+ @environment_id = environment_id
40
+ @logger = logger || Logger.instance
41
+ end
42
+
43
+ # Fetch configuration from API
44
+ #
45
+ # Makes a direct API call to the /config endpoint
46
+ # Returns a hash with status information
47
+ #
48
+ # @return [Hash] Result hash with :ok, :retryable, :status, and :data keys
49
+ def fetch
50
+ # Get the BaseService client
51
+ client = ApiManager.base_service_client
52
+ url_builder = UrlBuilder.instance
53
+
54
+ # Build the API endpoint URL
55
+ api_path = "/apprapp/feature/v1/instances/#{url_builder.guid}/config"
56
+
57
+ @logger.info("📡 Calling API: #{url_builder.base_service_url}#{api_path}")
58
+
59
+ # Make the API request
60
+ response = client.request(
61
+ method: "GET",
62
+ url: api_path,
63
+ headers: ApiManager.headers,
64
+ params: {
65
+ action: "sdkConfig",
66
+ collection_id: @collection_id,
67
+ environment_id: @environment_id
68
+ }
69
+ )
70
+
71
+ # Success case
72
+ if response.status == 200
73
+ @logger.info("✓ API call successful (200)")
74
+ {
75
+ ok: true,
76
+ retryable: false,
77
+ status: 200,
78
+ data: response.result
79
+ }
80
+ else
81
+ # Unexpected status code
82
+ @logger.warn("⚠️ Unexpected status code: #{response.status}")
83
+ {
84
+ ok: false,
85
+ retryable: true,
86
+ status: response.status,
87
+ data: nil
88
+ }
89
+ end
90
+ rescue IBMCloudSdkCore::ApiException => e
91
+ status_code = e.code.to_i
92
+ @logger.error("❌ API Exception: #{e.message} (Status: #{status_code})")
93
+
94
+ # Determine if error is retryable
95
+ # Non-retryable: 4xx except 429
96
+ # Retryable: 429, 5xx
97
+ retryable = status_code == 429 || status_code >= 500
98
+
99
+ {
100
+ ok: false,
101
+ retryable: retryable,
102
+ status: status_code,
103
+ data: nil
104
+ }
105
+ rescue StandardError => e
106
+ @logger.error("❌ Unexpected error: #{e.message}")
107
+ @logger.error(e.backtrace.first(3).join("\n"))
108
+
109
+ # Treat unexpected errors as retryable
110
+ {
111
+ ok: false,
112
+ retryable: true,
113
+ status: 500,
114
+ data: nil
115
+ }
116
+ end
117
+
118
+ # Display API response in a readable format
119
+ #
120
+ # @param data [Hash] API response data
121
+ def display_response(data)
122
+ # require 'json'
123
+
124
+ # @logger.info("\n📊 API Response Summary:")
125
+ # @logger.info("-" * 80)
126
+
127
+ # # Display features
128
+ # if data['features'] && data['features'].any?
129
+ # @logger.info("\n📋 Features (#{data['features'].length}):")
130
+ # data['features'].each_with_index do |feature, index|
131
+ # @logger.info(" #{index + 1}. #{feature['name']} (#{feature['feature_id']})")
132
+ # @logger.info(" Type: #{feature['type']}")
133
+ # @logger.info(" Enabled: #{feature['enabled']}")
134
+ # if feature['segment_rules'] && feature['segment_rules'].any?
135
+ # @logger.info(" Segment Rules: #{feature['segment_rules'].length}")
136
+ # end
137
+ # end
138
+ # else
139
+ # @logger.info("\n📋 Features: None")
140
+ # end
141
+
142
+ # # Display properties
143
+ # if data['properties'] && data['properties'].any?
144
+ # @logger.info("\n🔧 Properties (#{data['properties'].length}):")
145
+ # data['properties'].each_with_index do |property, index|
146
+ # @logger.info(" #{index + 1}. #{property['name']} (#{property['property_id']})")
147
+ # @logger.info(" Type: #{property['type']}")
148
+ # @logger.info(" Value: #{property['value']}")
149
+ # if property['segment_rules'] && property['segment_rules'].any?
150
+ # @logger.info(" Segment Rules: #{property['segment_rules'].length}")
151
+ # end
152
+ # end
153
+ # else
154
+ # @logger.info("\n🔧 Properties: None")
155
+ # end
156
+
157
+ # # Display segments
158
+ # if data['segments'] && data['segments'].any?
159
+ # @logger.info("\n👥 Segments (#{data['segments'].length}):")
160
+ # data['segments'].each_with_index do |segment, index|
161
+ # @logger.info(" #{index + 1}. #{segment['name']} (#{segment['segment_id']})")
162
+ # if segment['rules'] && segment['rules'].any?
163
+ # @logger.info(" Rules: #{segment['rules'].length}")
164
+ # end
165
+ # end
166
+ # else
167
+ # @logger.info("\n👥 Segments: None")
168
+ # end
169
+
170
+ # @logger.info("\n📄 Full JSON Response:")
171
+ # @logger.info("-" * 80)
172
+ # @logger.info(JSON.pretty_generate(data))
173
+ # @logger.info("=" * 80)
174
+ end
175
+
176
+ # Process API response and load to cache
177
+ #
178
+ # This method:
179
+ # 1. Takes the raw API response
180
+ # 2. Calls extract_configurations to parse and validate the data
181
+ # 3. Calls load_configurations_to_cache to store in cache
182
+ #
183
+ # @param api_response [Hash] Raw API response data
184
+ # @return [Boolean] true if processing was successful, false otherwise
185
+ def process_and_load_configurations(api_response)
186
+ return false unless api_response
187
+
188
+ begin
189
+ @logger.info("🔄 Processing API response...")
190
+
191
+ # Convert string keys to symbol keys if needed
192
+ symbolized_data = symbolize_keys(api_response)
193
+
194
+ # Extract configurations using utils.rb method
195
+ # This validates the data and extracts only the relevant features, properties, and segments
196
+ # for the specified environment and collection
197
+ extracted_config = extract_configurations(
198
+ symbolized_data,
199
+ @environment_id,
200
+ @collection_id
201
+ )
202
+
203
+ @logger.info("✓ Configurations extracted successfully")
204
+ @logger.info(" Features: #{extracted_config[:features]&.length || 0}")
205
+ @logger.info(" Properties: #{extracted_config[:properties]&.length || 0}")
206
+ @logger.info(" Segments: #{extracted_config[:segments]&.length || 0}")
207
+
208
+ # Load the extracted configurations to cache
209
+ success = load_configurations_to_cache(extracted_config)
210
+
211
+ if success
212
+ @logger.info("✅ Configurations processed and loaded successfully")
213
+ else
214
+ @logger.error("❌ Failed to load configurations to cache")
215
+ end
216
+
217
+ success
218
+ rescue StandardError => e
219
+ @logger.error("❌ Error processing API response: #{e.message}")
220
+ @logger.error(e.backtrace.first(5).join("\n"))
221
+ false
222
+ end
223
+ end
224
+
225
+ # Load configurations to cache
226
+ #
227
+ # Delegates to ConfigurationHandler singleton to maintain a single source of truth.
228
+ # This ensures all parts of the application use the same cache.
229
+ #
230
+ # @param data [Hash] Configuration data with :features, :properties, and :segments keys
231
+ # @return [Boolean] true if configurations were loaded successfully
232
+ def load_configurations_to_cache(data)
233
+ return false unless data
234
+
235
+ begin
236
+ @logger.info("📦 Loading configurations to ConfigurationHandler cache...")
237
+
238
+ # Delegate to ConfigurationHandler singleton (single source of truth)
239
+ handler = ConfigurationHandler.instance
240
+ handler.load_configurations_to_cache(data)
241
+
242
+ @logger.info("✅ Configurations loaded to cache successfully")
243
+ @logger.info(" ✓ Features: #{data[:features]&.length || 0}")
244
+ @logger.info(" ✓ Properties: #{data[:properties]&.length || 0}")
245
+ @logger.info(" ✓ Segments: #{data[:segments]&.length || 0}")
246
+
247
+ true
248
+ rescue StandardError => e
249
+ @logger.error("❌ Error loading configurations to cache: #{e.message}")
250
+ @logger.error(e.backtrace.first(5).join("\n"))
251
+ false
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,240 @@
1
+ # Copyright 2026 IBM Corp. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # frozen_string_literal: true
16
+
17
+ require "murmurhash3"
18
+
19
+ # Validates feature/property belongs to collection if it contains collections else gives true as default
20
+ # @param resource [Hash] The resource (feature or property)
21
+ # @param collection [String] The collection ID
22
+ # @return [Boolean]
23
+ def validate_resource(resource, collection)
24
+ # If collections is not present the resource data is coming from SDK APIs
25
+ return true unless resource.key?(:collections)
26
+
27
+ collections = resource[:collections]
28
+ raise "Improper collection format in resource data" unless collections.is_a?(Array)
29
+
30
+ collections.any? { |coll| coll[:collection_id] == collection }
31
+ end
32
+
33
+ # Appends segment ids to the provided set
34
+ # @param resource [Hash] The resource (feature or property)
35
+ # @param segment_ids [Set] Set to store segment IDs
36
+ def append_segment_id(resource, segment_ids)
37
+ return unless resource[:segment_rules]
38
+
39
+ resource[:segment_rules].each do |segment_rule|
40
+ segment_rule[:rules].each do |rule|
41
+ rule[:segments].each do |segment_id|
42
+ segment_ids.add(segment_id)
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # Prepares config data for extraction with validation
49
+ # @param data [Hash] Configuration data
50
+ # @param environment_id [String] Environment ID
51
+ # @return [Hash] Hash containing features, properties, and segments
52
+ def extract_environment_data(data, environment_id)
53
+ unless data.key?(:segments) && data[:segments].is_a?(Array) &&
54
+ data.key?(:environments) && data[:environments].is_a?(Array)
55
+ raise "Improper Data format present in configuration"
56
+ end
57
+
58
+ data[:environments].each do |environment|
59
+ next unless environment[:environment_id] == environment_id
60
+
61
+ result = {
62
+ features: environment[:features] || [],
63
+ properties: environment[:properties] || [],
64
+ segments: data[:segments]
65
+ }
66
+ puts "🔍 extract_environment_data: Found environment '#{environment_id}'"
67
+ puts " Features: #{result[:features].length}"
68
+ puts " Properties: #{result[:properties].length}"
69
+ puts " Segments: #{result[:segments].length}"
70
+ return result
71
+ end
72
+
73
+ raise "Matching environment not found in configuration"
74
+ end
75
+
76
+ # Returns object containing features, properties, segments after validation
77
+ # @param resource_data [Hash] Resource data containing features, properties, and segments
78
+ # @param collection [String] Collection ID
79
+ # @return [Hash] Hash containing validated features, properties, and segments
80
+ def extract_resources(resource_data, collection)
81
+ features = []
82
+ properties = []
83
+ segments = []
84
+ segment_ids = Set.new
85
+
86
+ puts "🔍 DEBUG extract_resources:"
87
+ puts " Features in resource_data: #{resource_data[:features]&.length || 0}"
88
+ puts " Properties in resource_data: #{resource_data[:properties]&.length || 0}"
89
+ puts " Collection to match: #{collection}"
90
+
91
+ # Appending features with validation to features array
92
+ resource_data[:features].each do |feature|
93
+ valid = validate_resource(feature, collection)
94
+ if valid
95
+ append_segment_id(feature, segment_ids)
96
+ features << feature
97
+ end
98
+ end
99
+
100
+ # Appending properties with validation to properties array
101
+ resource_data[:properties].each do |property|
102
+ valid = validate_resource(property, collection)
103
+ if valid
104
+ append_segment_id(property, segment_ids)
105
+ properties << property
106
+ end
107
+ end
108
+
109
+ # Appending only required segments to segments array and throw error if any required segment is absent
110
+ resource_data[:segments].each do |segment|
111
+ if segment_ids.include?(segment[:segment_id])
112
+ segments << segment
113
+ segment_ids.delete(segment[:segment_id])
114
+ end
115
+ end
116
+
117
+ raise "Required segment doesn't exist in provided segments" if segment_ids.size.positive?
118
+
119
+ {
120
+ features: features,
121
+ properties: properties,
122
+ segments: segments
123
+ }
124
+ end
125
+
126
+ # Unified parser for app-config data for new sdk-config format, export and promote data format
127
+ # @param configurations [Hash] Configuration JSON data (with symbol keys)
128
+ # @param environment [String] Environment ID
129
+ # @param collection [String] Collection ID
130
+ # @return [Hash] Hash containing features, properties, and segments
131
+ def extract_configurations(configurations, environment, collection)
132
+ puts "🔍 extract_configurations called"
133
+ puts " Environment: #{environment}"
134
+ puts " Collection: #{collection}"
135
+
136
+ # Check if data belongs to correct collection
137
+ raise "Improper/Missing collections in configuration" unless configurations.key?(:collections) && configurations[:collections].is_a?(Array)
138
+
139
+ match_found = false
140
+ configurations[:collections].each do |coll|
141
+ puts " Checking collection: #{coll[:collection_id]}"
142
+ if coll[:collection_id] == collection
143
+ match_found = true
144
+ break
145
+ end
146
+ end
147
+
148
+ raise "Required collection not found in collections" unless match_found
149
+
150
+ puts " Collection match found!"
151
+
152
+ # Data in SDK config/export/promote format
153
+ config_data = extract_environment_data(configurations, environment)
154
+ puts " After extract_environment_data: features=#{config_data[:features]&.length}"
155
+
156
+ result = extract_resources(config_data, collection)
157
+ puts " After extract_resources: features=#{result[:features]&.length}"
158
+
159
+ result
160
+ rescue StandardError => e
161
+ puts "❌ ERROR in extract_configurations: #{e.message}"
162
+ puts e.backtrace.first(5).join("\n")
163
+ raise "Extraction of configurations failed with error:\n #{e.message}"
164
+ end
165
+
166
+ # Helper method to convert string keys to symbol keys recursively
167
+ # @param obj [Object] The object to convert
168
+ # @return [Object] The converted object with symbol keys
169
+ def symbolize_keys(obj)
170
+ case obj
171
+ when Hash
172
+ obj.each_with_object({}) do |(key, value), result|
173
+ result[key.to_sym] = symbolize_keys(value)
174
+ end
175
+ when Array
176
+ obj.map { |item| symbolize_keys(item) }
177
+ else
178
+ obj
179
+ end
180
+ end
181
+
182
+ ##
183
+ # Compute hash using MurmurHash3
184
+ # @param str [String] String to hash
185
+ # @return [Integer] Hash value
186
+ def compute_hash(str)
187
+ seed = 0
188
+ MurmurHash3::V32.str_hash(str, seed)
189
+ end
190
+
191
+ ##
192
+ # Get normalized value for rollout percentage calculation
193
+ # @param str [String] String to normalize
194
+ # @return [Integer] Normalized value (0-100)
195
+ def get_normalized_value(str)
196
+ max_hash_value = 2**32
197
+ normalizer = 100
198
+ ((compute_hash(str).to_f / max_hash_value) * normalizer).floor
199
+ end
200
+
201
+ ##
202
+ # Parse progressive rollout phases into a sorted hash for timestamp-to-percentage lookups.
203
+ # @param configuration [Hash] Rollout config with start_at and phases
204
+ # @return [Hash] Sorted hash mapping timestamp (ms) -> percentage
205
+ # @raise [ArgumentError] If configuration is invalid
206
+ def parse_rollout_configuration_phases(configuration)
207
+ # Validate input
208
+ raise ArgumentError.new("Invalid rollout configuration") unless configuration&.key?(:start_at) && configuration[:phases].is_a?(Array)
209
+
210
+ # Time unit multipliers (to milliseconds)
211
+ multipliers = {
212
+ "days" => 86_400_000,
213
+ "hours" => 3_600_000,
214
+ "minutes" => 60_000
215
+ }
216
+
217
+ # Parse start timestamp
218
+ begin
219
+ start_timestamp = Time.parse(configuration[:start_at]).to_i * 1000 # Convert to milliseconds
220
+ rescue ArgumentError
221
+ raise ArgumentError.new("Invalid start_at: #{configuration[:start_at]}")
222
+ end
223
+
224
+ # Initialize result hash with initial entry
225
+ result = { 0 => 0 }
226
+ transition_time = start_timestamp
227
+
228
+ # Process each phase
229
+ configuration[:phases].each do |phase|
230
+ next unless phase.is_a?(Hash) && phase.key?(:percentage) && phase[:percentage].is_a?(Numeric)
231
+
232
+ result[transition_time] = phase[:percentage]
233
+
234
+ # Calculate next transition time if duration is specified
235
+ transition_time += multipliers[phase[:duration_type]] * phase[:duration] if phase[:duration] && phase[:duration_type] && multipliers[phase[:duration_type]]
236
+ end
237
+
238
+ # Return sorted hash (Ruby hashes maintain insertion order, so we sort by key)
239
+ result.sort.to_h
240
+ end