Skip to content

Commit 4cf5ef2

Browse files
ran-isenbergRan Isenberg
and
Ran Isenberg
authored
feature: add environment variables (#19)
Co-authored-by: Ran Isenberg <ran.isenberg@cyberark.com>
1 parent b000430 commit 4cf5ef2

File tree

17 files changed

+258
-14
lines changed

17 files changed

+258
-14
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The utilities cover multiple aspect of a production-ready service, including:
4848
1. [Logging](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-1-logging)
4949
2. [Observability: Monitoring and Tracing](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-2-observability)
5050
3. [Observability: Business Domain Metrics](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-3-business-domain-observability)
51-
4. Environment variables.
51+
4. [Environment variables](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-3-business-domain-observability)
5252
5. Input validation
5353
6. Features flags & dynamic configuration
5454

cdk/aws_lambda_handler_cookbook/service_stack/cookbook_construct.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ def __add_get_lambda_integration(self, api_name: aws_apigateway.Resource):
7777
code=_lambda.Code.from_asset(BUILD_FOLDER),
7878
handler='service.handlers.my_handler.my_handler',
7979
environment={
80-
POWERTOOLS_SERVICE_NAME: SERVICE_NAME,
81-
POWER_TOOLS_LOG_LEVEL: 'DEBUG',
80+
POWERTOOLS_SERVICE_NAME: SERVICE_NAME, # for logger, tracer and metrics
81+
POWER_TOOLS_LOG_LEVEL: 'DEBUG', # for logger
82+
'REST_API': 'https://www.ranthebuilder.cloud/api', # for env vars example
83+
'ROLE_ARN': 'arn:partition:service:region:account-id:resource-type:resource-id', # for env vars example
8284
},
8385
tracing=_lambda.Tracing.ACTIVE,
8486
retry_attempts=0,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---
2+
title: Environment Variables
3+
description: Environment Variables
4+
---
5+
Environment Variables decorator is a simple parser for environment variables that run at the start of the handler invocation.
6+
7+
![Environment Variables](../media/pydantic.png){: style="height:50%;width:20%"}
8+
9+
## Key features
10+
* A defined [Pydantic](https://pydantic-docs.helpmanual.io/){:target="_blank" rel="noopener"} schema for all required environment variables
11+
* A decorator that parses and validates environment variables, value constraints included
12+
* Global getter for parsed & valid schema dataclass with all environment variables
13+
14+
15+
16+
The best practice for handling environment variables is to validate & parse them according to a predefined schema as soon as the AWS Lambda function is triggered.
17+
18+
In case of misconfiguration, a validation exception is raised with all the relevant exception details.
19+
20+
21+
## Blog Reference
22+
Read more about the importance of validating environment variables and how this utility works. Click [**HERE**](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-3-business-domain-observability){:target="_blank" rel="noopener"}
23+
24+
25+
## Schema Definition
26+
27+
You need to define all your environment variables in a Pydantic schema class that extend Pydantic's BaseModel class.
28+
29+
For example:
30+
=== "schemas/env_vars.py"
31+
32+
```python hl_lines="5"
33+
from typing import Literal
34+
35+
from pydantic import BaseModel, HttpUrl, constr
36+
37+
class MyHandlerEnvVars(BaseModel):
38+
REST_API: HttpUrl
39+
ROLE_ARN: constr(min_length=20, max_length=2048)
40+
POWERTOOLS_SERVICE_NAME: constr(min_length=1)
41+
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']
42+
43+
```
44+
45+
All Pydantic schemas extend Pydantic’s ‘BaseModel’ class, turning them into a dataclass.
46+
47+
The schema defines four environment variables: ‘LOG_LEVEL,’ ‘POWERTOOLS_SERVICE_NAME,’ ‘ROLE_ARN,’ and ‘REST_API.’
48+
49+
This schema makes sure that:
50+
51+
- ‘LOG_LEVEL’ is one of the strings in the Literal list.
52+
- ‘ROLE_ARN’ exists and is between 20 and 2048 characters long, as defined here.
53+
- ‘REST_API’ is a valid HTTP URL.
54+
- ‘POWERTOOLS_SERVICE_NAME’ is a non-empty string.
55+
56+
Read [here](https://pydantic-docs.helpmanual.io/usage/models/){:target="_blank" rel="noopener"} about Pydantic Model capabilities.
57+
58+
## Decorator Usage
59+
The decorator 'init_environment_variables' is defined under the utility folder **service.utils.env_vars_parser.py** and imported in the handler.
60+
61+
The decorator requires a **model** parameter, which in this example is the name of the schema class we defined above.
62+
63+
=== "handlers/my_handler.py"
64+
65+
```python hl_lines="11"
66+
import json
67+
from http import HTTPStatus
68+
from typing import Any, Dict
69+
70+
from aws_lambda_powertools.utilities.typing import LambdaContext
71+
72+
from service.handlers.schemas.env_vars import MyHandlerEnvVars
73+
from service.handlers.utils.env_vars_parser import init_environment_variables
74+
75+
76+
@init_environment_variables(model=MyHandlerEnvVars)
77+
def my_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]:
78+
return {'statusCode': HTTPStatus.OK, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps({'message': 'success'})}
79+
```
80+
81+
## Global Getter Usage
82+
The getter function 'get_environment_variables' is defined under the utility folder **service.utils.env_vars_parser.py** and imported in the handler.
83+
84+
The getter function returns a parsed and validated global instance of the environment variables Pydantic schema class.
85+
86+
It can be used *anywhere* in the function code, not just the handler.
87+
88+
=== "handlers/my_handler.py"
89+
90+
```python hl_lines="13"
91+
import json
92+
from http import HTTPStatus
93+
from typing import Any, Dict
94+
95+
from aws_lambda_powertools.utilities.typing import LambdaContext
96+
97+
from service.handlers.schemas.env_vars import MyHandlerEnvVars
98+
from service.handlers.utils.env_vars_parser import get_environment_variables, init_environment_variables
99+
100+
101+
@init_environment_variables(model=MyHandlerEnvVars)
102+
def my_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]:
103+
env_vars: MyHandlerEnvVars = get_environment_variables()
104+
return {'statusCode': HTTPStatus.OK, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps({'message': 'success'})}
105+
```
106+
107+
108+
109+
## More Details
110+
111+
Read [here](https://pydantic-docs.helpmanual.io/usage/types/){:target="_blank" rel="noopener"} about Pydantic field types.
112+
113+
Read [here](https://pydantic-docs.helpmanual.io/usage/validators/){:target="_blank" rel="noopener"} about custom validators and advanced value constraints.

docs/best_practices/logger.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ It’s a wrapper of Python’s logging library that provides extra capabilities
1414

1515

1616
## Usage in Handler
17-
The logger is a singleton which is defined under the utility folder **service.utils.infra.py** and imported in the handler.
17+
The logger is a singleton which is defined under the utility folder **service.utils.observability.py** and imported in the handler.
1818

1919
## Blog Reference
2020
Read more about the importance of the logger and how to use AWS CloudWatch logs in my blog. Click [**HERE**](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-1-logging){:target="_blank" rel="noopener"}
2121

2222

23-
## More details
23+
## More Details
2424
You can find more information at the official documentation. Go to [https://awslabs.github.io/aws-lambda-powertools-python/latest/core/logger/](https://awslabs.github.io/aws-lambda-powertools-python/latest/core/logger/){:target="_blank" rel="noopener"}

docs/best_practices/metrics.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ These metrics can be visualized through [Amazon CloudWatch Console](https://cons
2020

2121

2222
## Usage in Handler
23-
The metrics is a singleton which is defined under the utility folder **service.utils.infra.py** and imported in the handler.
23+
The metrics is a singleton which is defined under the utility folder **service.utils.observability.py** and imported in the handler.
2424

2525
## Blog Reference
2626
Read more about the importance of the business KPis and metrics in my blog. Click [**HERE**](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-3-business-domain-observability){:target="_blank" rel="noopener"}
2727

2828

29-
## More details
29+
## More Details
3030
You can find more information at the official documentation. Go to [https://awslabs.github.io/aws-lambda-powertools-python/latest/core/metrics/](https://awslabs.github.io/aws-lambda-powertools-python/latest/core/metrics/){:target="_blank" rel="noopener"}

docs/best_practices/tracer.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ Tracer is a thin wrapper for [AWS X-Ray Python SDK](https://github.com/aws/aws-x
1414

1515

1616
## Usage in Handler
17-
The tracer is a singleton which is defined under the utility folder **service.utils.infra.py** and imported in the handler.
17+
The tracer is a singleton which is defined under the utility folder **service.utils.observability.py** and imported in the handler.
1818

1919
## Blog Reference
2020
Read more about the importance of observability and traces in my blog. Click [**HERE**](https://www.ranthebuilder.cloud/post/aws-lambda-cookbook-elevate-your-handler-s-code-part-2-observability){:target="_blank" rel="noopener"}
2121

2222

23-
## More details
23+
## More Details
2424
You can find more information at the official documentation. Go to [https://awslabs.github.io/aws-lambda-powertools-python/latest/core/tracer/](https://awslabs.github.io/aws-lambda-powertools-python/latest/core/tracer/){:target="_blank" rel="noopener"}

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The utilities cover multiple aspects of a production-ready service, including:
3636
* [**Logging**](best_practices/logger.md)
3737
* [**Observability: Monitoring and Tracing**](best_practices/tracer.md)
3838
* [**Observability: Business KPI Metrics**](best_practices/metrics.md)
39-
* **Environment variables** - Not published yet
39+
* [**Environment variables**](best_practices/environment_variables.md)
4040
* **Input validation** - Not published yet
4141
* **Features flags & dynamic configuration** - Not published yet
4242

docs/media/pydantic.png

32.6 KB
Loading

mkdocs.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ nav:
1313
- best_practices/logger.md
1414
- best_practices/tracer.md
1515
- best_practices/metrics.md
16+
- best_practices/environment_variables.md
1617

1718
theme:
1819
name: material
@@ -60,7 +61,11 @@ markdown_extensions:
6061
- attr_list
6162
- pymdownx.emoji
6263
- pymdownx.inlinehilite
63-
- pymdownx.superfences
64+
- pymdownx.superfences:
65+
custom_fences:
66+
- name: mermaid
67+
class: mermaid
68+
format: !!python/name:pymdownx.superfences.fence_code_format
6469

6570
copyright: Copyright &copy; 2022 Ran Isenberg
6671

service/handlers/my_handler.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,27 @@
55
from aws_lambda_powertools.metrics.metrics import MetricUnit
66
from aws_lambda_powertools.utilities.typing import LambdaContext
77

8-
from service.utils.infra import logger, metrics, tracer
8+
from service.handlers.schemas.env_vars import MyHandlerEnvVars
9+
from service.handlers.utils.env_vars_parser import get_environment_variables, init_environment_variables
10+
from service.handlers.utils.observability import logger, metrics, tracer
911

1012

1113
@tracer.capture_method(capture_response=False)
1214
def inner_function_example(event: Dict[str, Any]) -> Dict[str, Any]:
1315
return {}
1416

1517

18+
@init_environment_variables(model=MyHandlerEnvVars)
1619
@metrics.log_metrics
1720
@tracer.capture_lambda_handler(capture_response=False)
1821
def my_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]:
1922
logger.set_correlation_id(context.aws_request_id)
20-
logger.debug('my_handler is called, calling inner_function_example')
23+
logger.info('my_handler is called, calling inner_function_example')
24+
25+
env_vars: MyHandlerEnvVars = get_environment_variables()
26+
logger.debug('environment variables', extra=env_vars.dict())
27+
2128
inner_function_example(event)
22-
logger.debug('inner_function_example finished successfully')
29+
logger.info('inner_function_example finished successfully')
2330
metrics.add_metric(name='ValidEvents', unit=MetricUnit.Count, value=1)
2431
return {'statusCode': HTTPStatus.OK, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps({'message': 'success'})}
File renamed without changes.

service/handlers/schemas/env_vars.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Literal
2+
3+
from pydantic import BaseModel, HttpUrl, constr
4+
5+
6+
class Observability(BaseModel):
7+
POWERTOOLS_SERVICE_NAME: constr(min_length=1)
8+
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']
9+
10+
11+
class MyHandlerEnvVars(Observability):
12+
REST_API: HttpUrl
13+
ROLE_ARN: constr(min_length=20, max_length=2048)

service/handlers/utils/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
from typing import Any, TypeVar
3+
4+
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
5+
from pydantic import BaseModel, ValidationError
6+
7+
Model = TypeVar('Model', bound=BaseModel)
8+
9+
# global instance of the parsed Pydantic data class
10+
ENV_CONF: BaseModel = None
11+
12+
13+
@lambda_handler_decorator
14+
def init_environment_variables(handler, event, context, model: Model) -> Any:
15+
global ENV_CONF
16+
try:
17+
# parse the os environment variables dict
18+
ENV_CONF = model(**os.environ)
19+
except (ValidationError, TypeError) as exc:
20+
raise ValueError(f'failed to load environment variables, exception={str(exc)}') from exc
21+
22+
return handler(event, context)
23+
24+
25+
def get_environment_variables() -> BaseModel:
26+
global ENV_CONF
27+
if ENV_CONF is None:
28+
raise ValueError('get_environment_variables was called before init_environment_variables, environment variables were not loaded')
29+
return ENV_CONF
30+
31+
32+
# used for testing purposes
33+
def _clear_env_conf() -> None:
34+
global ENV_CONF
35+
ENV_CONF = None
File renamed without changes.

tests/unit/test_env_vars_parser.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
from typing import Any, Dict, Literal
3+
from unittest import mock
4+
5+
import pytest
6+
from pydantic import BaseModel, HttpUrl
7+
8+
from cdk.aws_lambda_handler_cookbook.service_stack.constants import POWER_TOOLS_LOG_LEVEL, POWERTOOLS_SERVICE_NAME, SERVICE_NAME
9+
from service.handlers.utils.env_vars_parser import _clear_env_conf, get_environment_variables, init_environment_variables
10+
from tests.utils import generate_context
11+
12+
13+
class MySchema(BaseModel):
14+
POWERTOOLS_SERVICE_NAME: str
15+
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']
16+
REST_API: HttpUrl
17+
18+
19+
@mock.patch.dict(os.environ, {
20+
POWERTOOLS_SERVICE_NAME: SERVICE_NAME,
21+
POWER_TOOLS_LOG_LEVEL: 'DEBUG',
22+
'REST_API': 'https://www.ranthebuilder.cloud/api'
23+
})
24+
def test_handler_schema_ok():
25+
26+
@init_environment_variables(model=MySchema)
27+
def my_handler(event, context) -> Dict[str, Any]:
28+
env_vars: MySchema = get_environment_variables()
29+
assert env_vars.POWERTOOLS_SERVICE_NAME == SERVICE_NAME
30+
assert env_vars.LOG_LEVEL == 'DEBUG'
31+
assert str(env_vars.REST_API) == 'https://www.ranthebuilder.cloud/api'
32+
return {}
33+
34+
my_handler({}, generate_context())
35+
_clear_env_conf()
36+
37+
38+
def test_handler_missing_env_var():
39+
40+
@init_environment_variables(model=MySchema)
41+
def my_handler1(event, context) -> Dict[str, Any]:
42+
return {}
43+
44+
with pytest.raises(ValueError):
45+
my_handler1({}, generate_context())
46+
47+
48+
@mock.patch.dict(os.environ, {POWERTOOLS_SERVICE_NAME: SERVICE_NAME, POWER_TOOLS_LOG_LEVEL: 'DEBUG', 'REST_API': 'fakeapi'})
49+
def test_handler_invalid_env_var_value():
50+
51+
@init_environment_variables(model=MySchema)
52+
def my_handler2(event, context) -> Dict[str, Any]:
53+
return {}
54+
55+
with pytest.raises(ValueError):
56+
my_handler2({}, generate_context())
57+
58+
59+
@mock.patch.dict(os.environ, {POWERTOOLS_SERVICE_NAME: SERVICE_NAME, POWER_TOOLS_LOG_LEVEL: 'DEBUG', 'REST_API': 'fakeapi'})
60+
def test_handler_get_env_var_without_init():
61+
62+
def my_handler3(event, context) -> Dict[str, Any]:
63+
get_environment_variables()
64+
return {}
65+
66+
with pytest.raises(ValueError):
67+
my_handler3({}, generate_context())

tests/unit/test_my_handler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
def init():
1414
os.environ[POWERTOOLS_SERVICE_NAME] = SERVICE_NAME
1515
os.environ[POWER_TOOLS_LOG_LEVEL] = 'DEBUG'
16+
os.environ['REST_API'] = 'https://www.ranthebuilder.cloud/api'
17+
os.environ['ROLE_ARN'] = 'arn:partition:service:region:account-id:resource-type:resource-id'
1618

1719

1820
def test_handler_200_ok():

0 commit comments

Comments
 (0)