Skip to content

Commit 101619b

Browse files
authored
Merge pull request #27 from IBM/prajwal-jarali-202509241308
Unification of Data format for API response and Bootstrap file
2 parents 4eec879 + 8c62f6b commit 101619b

File tree

15 files changed

+468
-251
lines changed

15 files changed

+468
-251
lines changed

.cra/.fileignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*

.secrets.baseline

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2025-04-25T05:22:50Z",
6+
"generated_at": "2025-09-24T13:09:33Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"

ibm_appconfiguration/configurations/configuration_handler.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
"""
1616
Internal class to handle the configuration.
1717
"""
18+
import json
1819
import os
1920
from typing import Dict, List, Any
2021
from threading import Timer, Thread
2122
from ibm_appconfiguration.configurations.internal.common import config_messages, config_constants
23+
from ibm_appconfiguration.version import __version__
2224
from .internal.utils.logger import Logger
25+
from .internal.utils.parser import extract_configurations, format_config
2326
from .internal.utils.validators import Validators
2427
from .models import Feature
2528
from .models import SegmentRules
@@ -147,7 +150,9 @@ def load_data(self):
147150
self.__persistent_data = FileManager.read_files(
148151
file_path=os.path.join(self.__persistent_cache_dir, 'appconfiguration.json'))
149152
if self.__persistent_data is not None:
150-
self.__load_configurations(self.__persistent_data)
153+
self.__load_configurations(
154+
extract_configurations(self.__persistent_data, self.__environment_id, self.__collection_id)
155+
)
151156
if not os.access(self.__persistent_cache_dir, os.W_OK):
152157
Logger.error(config_messages.ERROR_NO_WRITE_PERMISSION)
153158
return
@@ -156,11 +161,13 @@ def load_data(self):
156161
if self.__persistent_data is None or len(self.__persistent_data) == 0:
157162
bootstrap_file_data = FileManager.read_files(file_path=self.__bootstrap_file)
158163
if bootstrap_file_data is not None:
159-
self.__load_configurations(bootstrap_file_data)
164+
configurations = extract_configurations(bootstrap_file_data, self.__environment_id, self.__collection_id)
165+
self.__load_configurations(configurations)
166+
self.__write_to_persistent_storage(format_config(configurations, self.__environment_id, self.__collection_id),
167+
self.__persistent_cache_dir)
160168
else:
161169
Logger.error("Error reading bootstrap file data")
162170
return
163-
self.__write_to_persistent_storage(bootstrap_file_data, self.__persistent_cache_dir)
164171
if self.__configuration_update_listener and callable(self.__configuration_update_listener):
165172
self.__configuration_update_listener()
166173
else:
@@ -169,7 +176,9 @@ def load_data(self):
169176
else:
170177
bootstrap_file_data = FileManager.read_files(file_path=self.__bootstrap_file)
171178
if bootstrap_file_data is not None:
172-
self.__load_configurations(bootstrap_file_data)
179+
self.__load_configurations(
180+
extract_configurations(bootstrap_file_data, self.__environment_id, self.__collection_id)
181+
)
173182
else:
174183
Logger.error("Error reading bootstrap file data")
175184
return
@@ -271,7 +280,8 @@ def __fetch_config_data(self):
271280
def __start_web_socket(self):
272281
bearer_token = URLBuilder.get_iam_authenticator().token_manager.get_token()
273282
headers = {
274-
'Authorization': 'Bearer ' + bearer_token
283+
'Authorization': 'Bearer ' + bearer_token,
284+
'User-Agent': '{0}/{1}'.format(config_constants.SDK_NAME, __version__)
275285
}
276286
if self.__socket:
277287
self.__socket.cancel()
@@ -478,8 +488,8 @@ def __parse_rules(self, segment_rules: List) -> dict:
478488
Logger.debug(err)
479489
return rule_map
480490

481-
def __write_to_persistent_storage(self, json: dict, file_path: str):
482-
FileManager.store_files(json, os.path.join(file_path, 'appconfiguration.json'))
491+
def __write_to_persistent_storage(self, data: str, file_path: str):
492+
FileManager.store_files(json.dumps(json.loads(data), indent=2), os.path.join(file_path, 'appconfiguration.json'))
483493

484494
def __fetch_from_api(self):
485495
if self.__is_initialized:
@@ -506,16 +516,16 @@ def __fetch_from_api(self):
506516
Logger.info(config_messages.CONFIGURATIONS_FETCH_SUCCESS)
507517
response_data = response.get_result()
508518
try:
509-
response_data = dict(response_data)
510-
self.__load_configurations(response_data) # load response to cache maps
519+
configurations = extract_configurations(json.dumps(response_data), self.__environment_id, self.__collection_id)
520+
self.__load_configurations(configurations) # load response to cache maps
511521
if self.__configuration_update_listener and callable(self.__configuration_update_listener):
512522
self.__configuration_update_listener()
513523
# we have already loaded the configurations to feature & property dicts.
514524
# it is okay to "detach" the job of "writing to persistent location" from the main thread and finish the job using another thread.
515525
# But the thread shouldn't be a daemon thread, because the writing should complete even if the main thread has terminated.
516526
if self.__persistent_cache_dir:
517527
file_write_thread = Thread(target=self.__write_to_persistent_storage,
518-
args=(response_data, self.__persistent_cache_dir,))
528+
args=(format_config(configurations, self.__environment_id, self.__collection_id), self.__persistent_cache_dir))
519529
file_write_thread.start()
520530
except Exception as exception:
521531
Logger.error(f'error while while fetching {exception}')

ibm_appconfiguration/configurations/internal/utils/file_manager.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
"""
1919

2020
import fcntl
21-
import json
22-
from typing import Optional, Any
21+
from typing import Optional
2322

2423
from .logger import Logger
2524

@@ -28,25 +27,25 @@ class FileManager:
2827
"""FileManager to handle the cache"""
2928

3029
@classmethod
31-
def store_files(cls, json_data: {}, file_path: str) -> bool:
30+
def store_files(cls, data: str, file_path: str) -> bool:
3231
"""Store the file
3332
3433
Args:
35-
json_data: Data to be stored.
34+
data: Data to be stored.
3635
file_path: File path for the cache.
3736
"""
3837
try:
3938
with open(file_path, 'w') as cache:
4039
fcntl.flock(cache, fcntl.LOCK_EX | fcntl.LOCK_NB)
41-
json.dump(json_data, cache)
40+
cache.write(data)
4241
fcntl.flock(cache, fcntl.LOCK_UN)
4342
return True
4443
except Exception as err:
4544
Logger.error(err)
4645
return False
4746

4847
@classmethod
49-
def read_files(cls, file_path: str) -> Optional[Any]:
48+
def read_files(cls, file_path: str) -> Optional[str]:
5049
"""
5150
Read the data from the given path.
5251
@@ -59,9 +58,9 @@ def read_files(cls, file_path: str) -> Optional[Any]:
5958
try:
6059
with open(file_path, 'r') as file:
6160
fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
62-
data = json.load(file)
61+
data = file.read()
6362
fcntl.flock(file, fcntl.LOCK_UN)
64-
return data
63+
return data if len(data) > 0 else None
6564
except Exception as err:
6665
Logger.error(err)
6766
return None
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import json
2+
from typing import Any, Dict, List, Set
3+
4+
5+
def extract_environment_data(data: Dict[str, Any], environment_id: str) -> Dict[str, Any]:
6+
"""
7+
Prepares config data for extraction with validation.
8+
9+
:param data: The full JSON configuration as a dictionary.
10+
:param environment_id: The environment ID to extract.
11+
:return: A dictionary containing 'features', 'properties', and 'segments'.
12+
:raises: Exception if the format is invalid or environment not found.
13+
"""
14+
if not isinstance(data.get("segments"), list) or not isinstance(data.get("environments"), list):
15+
raise Exception("Improper Data format present in configuration")
16+
17+
for environment in data["environments"]:
18+
if environment.get("environment_id") == environment_id:
19+
return {
20+
"features": environment.get("features", []),
21+
"properties": environment.get("properties", []),
22+
"segments": data["segments"]
23+
}
24+
25+
raise Exception("Matching environment not found in configuration")
26+
27+
28+
def validate_resource(resource: Dict[str, Any], collection: str) -> bool:
29+
"""
30+
Validates if the feature/property belongs to the given collection.
31+
32+
:param resource: The feature or property dictionary.
33+
:param collection: The collection ID to match.
34+
:return: True if valid, False otherwise.
35+
:raises: Exception if collection format is invalid.
36+
"""
37+
if "collections" not in resource:
38+
return True
39+
40+
collections = resource["collections"]
41+
if not isinstance(collections, list):
42+
raise Exception("Improper collection format in resource data")
43+
44+
for col in collections:
45+
if col.get("collection_id") == collection:
46+
return True
47+
48+
return False
49+
50+
51+
def append_segment_ids(resource: Dict[str, Any], segment_ids: Set[str]):
52+
"""
53+
Appends segment IDs from the resource's segment rules into the given set.
54+
55+
:param resource: The feature or property dictionary.
56+
:param segment_ids: A set to accumulate segment IDs.
57+
"""
58+
for segment_rule in resource.get("segment_rules", []):
59+
for rule in segment_rule.get("rules", []):
60+
for segment_id in rule.get("segments", []):
61+
segment_ids.add(segment_id)
62+
63+
64+
def extract_resources(resource_data: Dict[str, Any], collection: str) -> Dict[str, List[Any]]:
65+
"""
66+
Extracts features, properties, and segments after validation.
67+
68+
:param resource_data: The environment-specific data.
69+
:param collection: The collection ID to validate against.
70+
:return: A dictionary with keys 'features', 'properties', and 'segments'.
71+
:raises: Exception if any required segment is missing.
72+
"""
73+
features = []
74+
properties = []
75+
segments = []
76+
required_segment_ids = set()
77+
78+
for feature in resource_data.get("features", []):
79+
if validate_resource(feature, collection):
80+
append_segment_ids(feature, required_segment_ids)
81+
features.append(feature)
82+
83+
for property_ in resource_data.get("properties", []):
84+
if validate_resource(property_, collection):
85+
append_segment_ids(property_, required_segment_ids)
86+
properties.append(property_)
87+
88+
available_segments = resource_data.get("segments", [])
89+
for segment in available_segments:
90+
if segment.get("segment_id") in required_segment_ids:
91+
segments.append(segment)
92+
required_segment_ids.remove(segment.get("segment_id"))
93+
94+
if len(required_segment_ids) > 0:
95+
raise Exception(f"Required segment doesn't exist in provided segments")
96+
97+
return {
98+
"features": features,
99+
"properties": properties,
100+
"segments": segments
101+
}
102+
103+
104+
def extract_configurations(data: str, environment: str, collection: str) -> Dict[str, List[Any]]:
105+
"""
106+
Unified parser for app-config data for new SDK/export/promote format.
107+
108+
:param data: Raw JSON string of the config.
109+
:param environment: The environment ID.
110+
:param collection: The collection ID.
111+
:return: A dictionary with 'features', 'properties', and 'segments'.
112+
:raises: Exception on any validation or format error.
113+
"""
114+
try:
115+
configurations = json.loads(data)
116+
117+
if "collections" not in configurations or not isinstance(configurations["collections"], list):
118+
raise Exception("Improper/Missing collections in configuration")
119+
120+
if not any(col.get("collection_id") == collection for col in configurations["collections"]):
121+
raise Exception("Required collection not found in collections")
122+
123+
config_data = extract_environment_data(configurations, environment)
124+
return extract_resources(config_data, collection)
125+
126+
except Exception as e:
127+
raise Exception(f"Extraction of configurations failed with error:\n {str(e)}")
128+
129+
130+
def format_config(res: Dict[str, List[Any]], environment_id: str, collection_id: str) -> str:
131+
"""
132+
Formats the extracted resources into unified config format.
133+
134+
:param res: The extracted config (from `extract_configurations`).
135+
:param environment_id: The environment ID to include.
136+
:param collection_id: The collection ID to include.
137+
:return: A formatted configuration dictionary.
138+
"""
139+
return json.dumps({
140+
"environments": [
141+
{
142+
"environment_id": environment_id,
143+
"features": res.get("features", []),
144+
"properties": res.get("properties", [])
145+
}
146+
],
147+
"collections": [{"collection_id": collection_id}],
148+
"segments": res.get("segments", [])
149+
})

ibm_appconfiguration/configurations/internal/utils/url_builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,12 @@ def init_with_collection_id(cls, collection_id='', environment_id='', region='',
8484
cls.__iam_url = "https://iam.cloud.ibm.com"
8585
cls.__web_socket_base = cls.__wss + region + cls.__base_url
8686

87-
cls.__config_path = '{0}{1}{2}/collections/{3}/{4}?environment_id={5}'.format(
87+
cls.__config_path = '{0}{1}{2}/{3}?action=sdkConfig&collection_id={4}&environment_id={5}'.format(
8888
cls.__service,
8989
cls.__feature_path,
9090
guid,
91-
collection_id,
9291
cls.__config,
92+
collection_id,
9393
environment_id)
9494
cls.__metering_path = '{0}{1}{2}/usage'.format(cls.__service,
9595
cls.__events_path,

ibm_appconfiguration/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
"""
1616
Version of ibm-appconfiguration-python-sdk
1717
"""
18-
__version__ = '0.3.8'
18+
__version__ = '0.3.9'

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from setuptools import setup, find_packages
1313

1414
NAME = "ibm-appconfiguration-python-sdk"
15-
VERSION = "0.3.8"
15+
VERSION = "0.3.9"
1616
# To install the library, run the following
1717
#
1818
# python setup.py install

0 commit comments

Comments
 (0)