Skip to content

Commit b8a5b25

Browse files
author
Ran Isenberg
committed
feat: Add AppConfig parameter provider
1 parent 06d58d4 commit b8a5b25

File tree

5 files changed

+288
-8
lines changed

5 files changed

+288
-8
lines changed

Diff for: aws_lambda_powertools/utilities/parameters/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@
44
Parameter retrieval and caching utility
55
"""
66

7+
from .appconfig import AppConfigProvider, get_app_config
78
from .base import BaseProvider
89
from .dynamodb import DynamoDBProvider
910
from .exceptions import GetParameterError, TransformParameterError
1011
from .secrets import SecretsProvider, get_secret
1112
from .ssm import SSMProvider, get_parameter, get_parameters
1213

1314
__all__ = [
15+
"AppConfigProvider",
1416
"BaseProvider",
1517
"GetParameterError",
1618
"DynamoDBProvider",
1719
"SecretsProvider",
1820
"SSMProvider",
1921
"TransformParameterError",
22+
"get_app_config",
2023
"get_parameter",
2124
"get_parameters",
2225
"get_secret",
+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
AWS App Config configuration retrieval and caching utility
3+
"""
4+
5+
6+
import os
7+
from typing import Dict, Optional, Union
8+
from uuid import uuid4
9+
10+
import boto3
11+
from botocore.config import Config
12+
13+
from .base import DEFAULT_PROVIDERS, BaseProvider
14+
15+
CLIENT_ID = str(uuid4())
16+
17+
18+
class AppConfigProvider(BaseProvider):
19+
"""
20+
AWS App Config Provider
21+
22+
Parameters
23+
----------
24+
environment: str
25+
Environment of the configuration to pass during client initialization
26+
application: str, optional
27+
Application of the configuration to pass during client initialization
28+
config: botocore.config.Config, optional
29+
Botocore configuration to pass during client initialization
30+
31+
Example
32+
-------
33+
**Retrieves the latest configuration value from App Config**
34+
35+
>>> from aws_lambda_powertools.utilities.parameters import AppConfigProvider
36+
>>> appconf_provider = parameters.AppConfigProvider(environment="my_env", application="my_app")
37+
>>>
38+
>>> value : bytes = appconf_provider.get("my_conf")
39+
>>>
40+
>>> print(value)
41+
My configuration value
42+
43+
**Retrieves a configuration value from App Config in another AWS region**
44+
45+
>>> from botocore.config import Config
46+
>>> from aws_lambda_powertools.utilities.parameters import AppConfigProvider
47+
>>>
48+
>>> config = Config(region_name="us-west-1")
49+
>>> appconf_provider = parameters.AppConfigProvider(environment="my_env", application="my_app", config=config)
50+
>>>
51+
>>> value : bytes = appconf_provider.get("my_conf")
52+
>>>
53+
>>> print(value)
54+
My configuration value
55+
56+
"""
57+
58+
client = None
59+
60+
def __init__(
61+
self, environment: str, application: Optional[str] = None, config: Optional[Config] = None,
62+
):
63+
"""
64+
Initialize the App Config client
65+
"""
66+
67+
config = config or Config()
68+
self.client = boto3.client("appconfig", config=config)
69+
self.application = application or os.getenv("POWERTOOLS_SERVICE_NAME") or "application_undefined"
70+
self.environment = environment
71+
self.current_version = ""
72+
73+
super().__init__()
74+
75+
def _get(self, name: str, **sdk_options) -> str:
76+
"""
77+
Retrieve a parameter value from AWS App config.
78+
79+
Parameters
80+
----------
81+
name: str
82+
Name of the configuration
83+
environment: str
84+
Environment of the configuration
85+
sdk_options: dict, optional
86+
Dictionary of options that will be passed to the Parameter Store get_parameter API call
87+
"""
88+
89+
sdk_options["Configuration"] = name
90+
sdk_options["Application"] = self.application
91+
sdk_options["Environment"] = self.environment
92+
sdk_options["ClientId"] = CLIENT_ID
93+
94+
response = self.client.get_configuration(**sdk_options)
95+
return response["Content"].read() # read() of botocore.response.StreamingBody
96+
97+
def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]:
98+
"""
99+
Retrieving multiple parameter values is not supported with AWS App Config Provider
100+
"""
101+
raise NotImplementedError()
102+
103+
104+
def get_app_config(
105+
name: str, environment: str, application: Optional[str] = None, transform: Optional[str] = None, **sdk_options
106+
) -> Union[str, list, dict, bytes]:
107+
"""
108+
Retrieve a configuration value from AWS App Config.
109+
110+
Parameters
111+
----------
112+
name: str
113+
Name of the configuration
114+
environment: str
115+
Environment of the configuration
116+
application: str
117+
Application of the configuration
118+
transform: str, optional
119+
Transforms the content from a JSON object ('json') or base64 binary string ('binary')
120+
sdk_options: dict, optional
121+
Dictionary of options that will be passed to the Parameter Store get_parameter API call
122+
123+
Raises
124+
------
125+
GetParameterError
126+
When the parameter provider fails to retrieve a parameter value for
127+
a given name.
128+
TransformParameterError
129+
When the parameter provider fails to transform a parameter value.
130+
131+
Example
132+
-------
133+
**Retrieves the latest version of configuration value from App Config**
134+
135+
>>> from aws_lambda_powertools.utilities.parameters import get_app_config
136+
>>>
137+
>>> value = get_app_config("my_config", environment="my_env", application="my_env")
138+
>>>
139+
>>> print(value)
140+
My configuration value
141+
142+
**Retrieves a confiugration value and decodes it using a JSON decoder**
143+
144+
>>> from aws_lambda_powertools.utilities.parameters import get_parameter
145+
>>>
146+
>>> value = get_app_config("my_config", environment="my_env", application="my_env", transform='json')
147+
>>>
148+
>>> print(value)
149+
My configuration's JSON value
150+
"""
151+
152+
# Only create the provider if this function is called at least once
153+
if "appconfig" not in DEFAULT_PROVIDERS:
154+
DEFAULT_PROVIDERS["appconfig"] = AppConfigProvider(environment=environment, application=application)
155+
156+
sdk_options["ClientId"] = CLIENT_ID
157+
158+
return DEFAULT_PROVIDERS["appconfig"].get(name, transform=transform, **sdk_options)

Diff for: aws_lambda_powertools/utilities/parameters/base.py

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DEFAULT_PROVIDERS = {}
1818
TRANSFORM_METHOD_JSON = "json"
1919
TRANSFORM_METHOD_BINARY = "binary"
20+
TRANSFORM_METHOD_YAML = "yaml"
2021
SUPPORTED_TRANSFORM_METHODS = [TRANSFORM_METHOD_JSON, TRANSFORM_METHOD_BINARY]
2122

2223

@@ -230,6 +231,8 @@ def transform_value(value: str, transform: str, raise_on_transform_error: bool =
230231
return json.loads(value)
231232
elif transform == TRANSFORM_METHOD_BINARY:
232233
return base64.b64decode(value)
234+
elif transform == TRANSFORM_METHOD_YAML:
235+
raise NotImplementedError
233236
else:
234237
raise ValueError(f"Invalid transform type '{transform}'")
235238

Diff for: docs/content/utilities/parameters.mdx

+41-8
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ SSM Parameter Store | `get_parameters`, `SSMProvider.get_multiple` | `ssm:GetPar
2424
Secrets Manager | `get_secret`, `SecretsManager.get` | `secretsmanager:GetSecretValue`
2525
DynamoDB | `DynamoDBProvider.get` | `dynamodb:GetItem`
2626
DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb:Query`
27+
App Config | `AppConfigProvider.get_app_config`, `get_app_config` | `appconfig:GetConfiguration`
2728

2829
## SSM Parameter Store
2930

@@ -204,6 +205,37 @@ def handler(event, context):
204205
value = dynamodb_provider.get("my-parameter")
205206
```
206207

208+
## App Config
209+
210+
For configurations stored in App Config, use `get_app_config`.
211+
The following will retrieve the latest version and store it in the cache.
212+
213+
```python:title=appconfig.py
214+
from aws_lambda_powertools.utilities import parameters
215+
216+
def handler(event, context):
217+
# Retrieve a single configuration, latest version
218+
value: bytes = parameters.get_app_config(name="my_configuration", environment="my_env", application="my_app")
219+
```
220+
221+
### AppConfigProvider class
222+
223+
Alternatively, you can use the `AppConfigProvider` class, which give more flexibility, such as the ability to configure the underlying SDK client.
224+
225+
This can be used to retrieve values from other regions, change the retry behavior, etc.
226+
227+
```python:title=appconfig.py
228+
from aws_lambda_powertools.utilities import parameters
229+
from botocore.config import Config
230+
231+
config = Config(region_name="us-west-1")
232+
appconf_provider = parameters.AppConfigProvider(environment="my_env", application="my_app", config=config)
233+
234+
def handler(event, context):
235+
# Retrieve a single secret
236+
value : bytes = appconf_provider.get("my_conf")
237+
```
238+
207239
## Create your own provider
208240

209241
You can create your own custom parameter store provider by inheriting the `BaseProvider` class, and implementing both `_get()` and `_get_multiple()` methods to retrieve a single, or multiple parameters from your custom store.
@@ -334,13 +366,14 @@ def handler(event, context):
334366

335367
Here is the mapping between this utility's functions and methods and the underlying SDK:
336368

337-
| Provider | Function/Method | Client name | Function name |
338-
|---------------------|---------------------------------|-------------|---------------|
339-
| SSM Parameter Store | `get_parameter` | `ssm` | [get_parameter](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter) |
340-
| SSM Parameter Store | `get_parameters` | `ssm` | [get_parameters_by_path](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameters_by_path) |
341-
| SSM Parameter Store | `SSMProvider.get` | `ssm` | [get_parameter](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter) |
342-
| SSM Parameter Store | `SSMProvider.get_multiple` | `ssm` | [get_parameters_by_path](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameters_by_path) |
369+
| Provider | Function/Method | Client name | Function name |
370+
|---------------------|---------------------------------|------------------|----------------|
371+
| SSM Parameter Store | `get_parameter` | `ssm` | [get_parameter](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter) |
372+
| SSM Parameter Store | `get_parameters` | `ssm` | [get_parameters_by_path](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameters_by_path) |
373+
| SSM Parameter Store | `SSMProvider.get` | `ssm` | [get_parameter](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameter) |
374+
| SSM Parameter Store | `SSMProvider.get_multiple` | `ssm` | [get_parameters_by_path](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ssm.html#SSM.Client.get_parameters_by_path) |
343375
| Secrets Manager | `get_secret` | `secretsmanager` | [get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.get_secret_value) |
344376
| Secrets Manager | `SecretsManager.get` | `secretsmanager` | [get_secret_value](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html#SecretsManager.Client.get_secret_value) |
345-
| DynamoDB | `DynamoDBProvider.get` | `dynamodb` ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table)) | [get_item](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table.get_item)
346-
| DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb` ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table)) | [query](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table.query)
377+
| DynamoDB | `DynamoDBProvider.get` | `dynamodb` | ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table)) | [get_item](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table.get_item)
378+
| DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb` | ([Table resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table)) | [query](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Table.query)
379+
| App Config | `get_app_config` | `appconfig` | [get_configuration](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/appconfig.html#AppConfig.Client.get_configuration) |

Diff for: tests/functional/test_utilities_parameters.py

+83
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import random
44
import string
55
from datetime import datetime, timedelta
6+
from io import BytesIO
67
from typing import Dict
78

89
import pytest
910
from boto3.dynamodb.conditions import Key
1011
from botocore import stub
1112
from botocore.config import Config
13+
from botocore.response import StreamingBody
1214

1315
from aws_lambda_powertools.utilities import parameters
1416
from aws_lambda_powertools.utilities.parameters.base import BaseProvider, ExpirableValue
@@ -1451,6 +1453,87 @@ def _get_multiple(self, path: str, **kwargs) -> Dict[str, str]:
14511453
assert value == mock_value
14521454

14531455

1456+
def test_appconf_provider_get_configuration_json_content_type(mock_name, config):
1457+
"""
1458+
Test get_configuration.get with default values
1459+
"""
1460+
1461+
# Create a new provider
1462+
environment = "dev"
1463+
application = "myapp"
1464+
provider = parameters.AppConfigProvider(environment=environment, application=application, config=config)
1465+
1466+
mock_body_json = {"myenvvar1": "Black Panther", "myenvvar2": 3}
1467+
encoded_message = json.dumps(mock_body_json).encode("utf-8")
1468+
mock_value = StreamingBody(BytesIO(encoded_message), len(encoded_message))
1469+
1470+
# Stub the boto3 client
1471+
stubber = stub.Stubber(provider.client)
1472+
response = {"Content": mock_value, "ConfigurationVersion": "1", "ContentType": "application/json"}
1473+
stubber.add_response("get_configuration", response)
1474+
stubber.activate()
1475+
1476+
try:
1477+
value = provider.get(mock_name, transform="json", ClientConfigurationVersion="2")
1478+
1479+
assert value == mock_body_json
1480+
stubber.assert_no_pending_responses()
1481+
finally:
1482+
stubber.deactivate()
1483+
1484+
1485+
def test_appconf_provider_get_configuration_no_transform(mock_name, config):
1486+
"""
1487+
Test appconfigprovider.get with default values
1488+
"""
1489+
1490+
# Create a new provider
1491+
environment = "dev"
1492+
application = "myapp"
1493+
provider = parameters.AppConfigProvider(environment=environment, application=application, config=config)
1494+
1495+
mock_body_json = {"myenvvar1": "Black Panther", "myenvvar2": 3}
1496+
encoded_message = json.dumps(mock_body_json).encode("utf-8")
1497+
mock_value = StreamingBody(BytesIO(encoded_message), len(encoded_message))
1498+
1499+
# Stub the boto3 client
1500+
stubber = stub.Stubber(provider.client)
1501+
response = {"Content": mock_value, "ConfigurationVersion": "1", "ContentType": "application/json"}
1502+
stubber.add_response("get_configuration", response)
1503+
stubber.activate()
1504+
1505+
try:
1506+
value = provider.get(mock_name)
1507+
str_value = value.decode("utf-8")
1508+
assert str_value == json.dumps(mock_body_json)
1509+
stubber.assert_no_pending_responses()
1510+
finally:
1511+
stubber.deactivate()
1512+
1513+
1514+
def test_appconf_get_app_config_no_transform(monkeypatch, mock_name):
1515+
"""
1516+
Test get_app_config()
1517+
"""
1518+
mock_body_json = {"myenvvar1": "Black Panther", "myenvvar2": 3}
1519+
1520+
class TestProvider(BaseProvider):
1521+
def _get(self, name: str, **kwargs) -> str:
1522+
assert name == mock_name
1523+
return json.dumps(mock_body_json).encode("utf-8")
1524+
1525+
def _get_multiple(self, path: str, **kwargs) -> Dict[str, str]:
1526+
raise NotImplementedError()
1527+
1528+
monkeypatch.setitem(parameters.base.DEFAULT_PROVIDERS, "appconfig", TestProvider())
1529+
1530+
environment = "dev"
1531+
application = "myapp"
1532+
value = parameters.get_app_config(mock_name, environment=environment, application=application)
1533+
str_value = value.decode("utf-8")
1534+
assert str_value == json.dumps(mock_body_json)
1535+
1536+
14541537
def test_transform_value_json(mock_value):
14551538
"""
14561539
Test transform_value() with a json transform

0 commit comments

Comments
 (0)