clear-skies-aws 2.0.1__py3-none-any.whl → 2.0.3__py3-none-any.whl
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.
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/METADATA +2 -2
- clear_skies_aws-2.0.3.dist-info/RECORD +63 -0
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/WHEEL +1 -1
- clearskies_aws/__init__.py +27 -0
- clearskies_aws/actions/__init__.py +15 -0
- clearskies_aws/actions/action_aws.py +135 -0
- clearskies_aws/actions/assume_role.py +115 -0
- clearskies_aws/actions/ses.py +203 -0
- clearskies_aws/actions/sns.py +61 -0
- clearskies_aws/actions/sqs.py +81 -0
- clearskies_aws/actions/step_function.py +73 -0
- clearskies_aws/backends/__init__.py +19 -0
- clearskies_aws/backends/backend.py +106 -0
- clearskies_aws/backends/dynamo_db_backend.py +609 -0
- clearskies_aws/backends/dynamo_db_condition_parser.py +325 -0
- clearskies_aws/backends/dynamo_db_parti_ql_backend.py +965 -0
- clearskies_aws/backends/sqs_backend.py +61 -0
- clearskies_aws/configs/__init__.py +0 -0
- clearskies_aws/contexts/__init__.py +23 -0
- clearskies_aws/contexts/cli_web_socket_mock.py +20 -0
- clearskies_aws/contexts/lambda_alb.py +81 -0
- clearskies_aws/contexts/lambda_api_gateway.py +81 -0
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +79 -0
- clearskies_aws/contexts/lambda_invoke.py +138 -0
- clearskies_aws/contexts/lambda_sns.py +124 -0
- clearskies_aws/contexts/lambda_sqs_standard.py +139 -0
- clearskies_aws/di/__init__.py +6 -0
- clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
- clearskies_aws/di/inject/__init__.py +6 -0
- clearskies_aws/di/inject/boto3.py +15 -0
- clearskies_aws/di/inject/boto3_session.py +13 -0
- clearskies_aws/di/inject/parameter_store.py +15 -0
- clearskies_aws/endpoints/__init__.py +1 -0
- clearskies_aws/endpoints/secrets_manager_rotation.py +194 -0
- clearskies_aws/endpoints/simple_body_routing.py +41 -0
- clearskies_aws/input_outputs/__init__.py +21 -0
- clearskies_aws/input_outputs/cli_web_socket_mock.py +20 -0
- clearskies_aws/input_outputs/lambda_alb.py +53 -0
- clearskies_aws/input_outputs/lambda_api_gateway.py +123 -0
- clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +73 -0
- clearskies_aws/input_outputs/lambda_input_output.py +89 -0
- clearskies_aws/input_outputs/lambda_invoke.py +88 -0
- clearskies_aws/input_outputs/lambda_sns.py +88 -0
- clearskies_aws/input_outputs/lambda_sqs_standard.py +86 -0
- clearskies_aws/mocks/__init__.py +1 -0
- clearskies_aws/mocks/actions/__init__.py +6 -0
- clearskies_aws/mocks/actions/ses.py +34 -0
- clearskies_aws/mocks/actions/sns.py +29 -0
- clearskies_aws/mocks/actions/sqs.py +29 -0
- clearskies_aws/mocks/actions/step_function.py +32 -0
- clearskies_aws/models/__init__.py +1 -0
- clearskies_aws/models/web_socket_connection_model.py +182 -0
- clearskies_aws/secrets/__init__.py +13 -0
- clearskies_aws/secrets/additional_configs/__init__.py +62 -0
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +39 -0
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +96 -0
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +80 -0
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +162 -0
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +60 -0
- clearskies_aws/secrets/parameter_store.py +52 -0
- clearskies_aws/secrets/secrets.py +16 -0
- clearskies_aws/secrets/secrets_manager.py +96 -0
- clear_skies_aws-2.0.1.dist-info/RECORD +0 -4
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies.configs import String
|
|
6
|
+
from clearskies.input_outputs import Headers
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LambdaApiGateway(lambda_input_output.LambdaInputOutput):
|
|
12
|
+
"""API Gateway v1 and v2 Lambda input/output handler."""
|
|
13
|
+
|
|
14
|
+
resource = String(default="")
|
|
15
|
+
|
|
16
|
+
def __init__(self, event: dict, context: dict[str, Any]):
|
|
17
|
+
# Call parent constructor
|
|
18
|
+
super().__init__(event, context)
|
|
19
|
+
|
|
20
|
+
# Determine API Gateway version and parse accordingly
|
|
21
|
+
version = self._detect_version(event)
|
|
22
|
+
if version == "1.0":
|
|
23
|
+
self._parse_event_v1(event)
|
|
24
|
+
elif version == "2.0":
|
|
25
|
+
self._parse_event_v2(event)
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError(f"Unsupported API Gateway event version: {version}")
|
|
28
|
+
|
|
29
|
+
def _detect_version(self, event: dict) -> str:
|
|
30
|
+
"""Detect API Gateway version from event structure."""
|
|
31
|
+
if "version" in event:
|
|
32
|
+
return event["version"]
|
|
33
|
+
elif "httpMethod" in event:
|
|
34
|
+
return "1.0" # v1 has httpMethod at root level
|
|
35
|
+
elif "requestContext" in event and "http" in event["requestContext"]:
|
|
36
|
+
return "2.0" # v2 has http in requestContext
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError("Unable to determine API Gateway version from event structure")
|
|
39
|
+
|
|
40
|
+
def _parse_event_v1(self, event: dict) -> None:
|
|
41
|
+
"""Parse API Gateway v1 event structure."""
|
|
42
|
+
self.request_method = event.get("httpMethod", "GET").upper()
|
|
43
|
+
self.path = event.get("path", "/")
|
|
44
|
+
self.resource = event.get("resource", "")
|
|
45
|
+
|
|
46
|
+
# Extract query parameters (v1 has both single and multi-value)
|
|
47
|
+
self.query_parameters = {
|
|
48
|
+
**(event.get("queryStringParameters") or {}),
|
|
49
|
+
**(event.get("multiValueQueryStringParameters") or {}),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Extract headers (v1 has both single and multi-value)
|
|
53
|
+
headers_dict = {}
|
|
54
|
+
for key, value in {
|
|
55
|
+
**event.get("headers", {}),
|
|
56
|
+
**event.get("multiValueHeaders", {}),
|
|
57
|
+
}.items():
|
|
58
|
+
headers_dict[key.lower()] = str(value)
|
|
59
|
+
|
|
60
|
+
self.request_headers = Headers(headers_dict)
|
|
61
|
+
|
|
62
|
+
def _parse_event_v2(self, event: dict) -> None:
|
|
63
|
+
"""Parse API Gateway v2 event structure."""
|
|
64
|
+
request_context = event.get("requestContext", {})
|
|
65
|
+
http_context = request_context.get("http", {})
|
|
66
|
+
|
|
67
|
+
self.request_method = http_context.get("method", "GET").upper()
|
|
68
|
+
self.path = http_context.get("path", "/")
|
|
69
|
+
# v2 doesn't have resource field
|
|
70
|
+
self.resource = ""
|
|
71
|
+
|
|
72
|
+
# Extract query parameters (v2 only has single values)
|
|
73
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
74
|
+
|
|
75
|
+
# Extract headers (v2 only has single value headers)
|
|
76
|
+
headers_dict = {}
|
|
77
|
+
for key, value in event.get("headers", {}).items():
|
|
78
|
+
headers_dict[key.lower()] = str(value)
|
|
79
|
+
|
|
80
|
+
self.request_headers = Headers(headers_dict)
|
|
81
|
+
|
|
82
|
+
def get_client_ip(self) -> str:
|
|
83
|
+
"""Get the client IP address from API Gateway event."""
|
|
84
|
+
request_context = self.event.get("requestContext", {})
|
|
85
|
+
|
|
86
|
+
# Try v1 format first (identity.sourceIp)
|
|
87
|
+
identity = request_context.get("identity", {})
|
|
88
|
+
if "sourceIp" in identity:
|
|
89
|
+
return identity["sourceIp"]
|
|
90
|
+
|
|
91
|
+
# Try v2 format (http.sourceIp)
|
|
92
|
+
http_context = request_context.get("http", {})
|
|
93
|
+
if "sourceIp" in http_context:
|
|
94
|
+
return http_context["sourceIp"]
|
|
95
|
+
|
|
96
|
+
raise ValueError("Unable to find the client ip inside the API Gateway")
|
|
97
|
+
|
|
98
|
+
def get_protocol(self) -> str:
|
|
99
|
+
"""Get the protocol from API Gateway request context."""
|
|
100
|
+
request_context = self.event.get("requestContext", {})
|
|
101
|
+
|
|
102
|
+
# Try v2 format first (has explicit protocol)
|
|
103
|
+
http_context = request_context.get("http", {})
|
|
104
|
+
if "protocol" in http_context:
|
|
105
|
+
protocol = http_context["protocol"]
|
|
106
|
+
return "https" if protocol.upper().startswith("HTTPS") else "http"
|
|
107
|
+
|
|
108
|
+
# v1 defaults to HTTPS
|
|
109
|
+
return "https"
|
|
110
|
+
|
|
111
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
112
|
+
"""Provide API Gateway specific context data."""
|
|
113
|
+
request_context = self.event.get("requestContext", {})
|
|
114
|
+
http_context = request_context.get("http", {})
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
**super().context_specifics(),
|
|
118
|
+
"resource": self.resource,
|
|
119
|
+
"stage": request_context.get("stage"),
|
|
120
|
+
"request_id": request_context.get("requestId"),
|
|
121
|
+
"api_id": request_context.get("apiId"),
|
|
122
|
+
"api_version": self._detect_version(self.event),
|
|
123
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.configs import String
|
|
7
|
+
from clearskies.input_outputs import Headers
|
|
8
|
+
|
|
9
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaApiGatewayWebSocket(lambda_input_output.LambdaInputOutput):
|
|
13
|
+
"""Api Gateway WebSocket specific Lambda input/output handler."""
|
|
14
|
+
|
|
15
|
+
route_key = String(default="")
|
|
16
|
+
connection_id = String(default="")
|
|
17
|
+
|
|
18
|
+
def __init__(self, event: dict[str, Any], context: dict[str, Any], url: str = ""):
|
|
19
|
+
# Call parent constructor
|
|
20
|
+
super().__init__(event, context)
|
|
21
|
+
|
|
22
|
+
self.path = url
|
|
23
|
+
|
|
24
|
+
# WebSocket specific initialization
|
|
25
|
+
request_context = event.get("requestContext", {})
|
|
26
|
+
|
|
27
|
+
# WebSocket uses route_key, but doesn't have either a route or a method
|
|
28
|
+
self.route_key = request_context.get("routeKey", "GET")
|
|
29
|
+
self.request_method = self.route_key.upper() # For compatibility
|
|
30
|
+
|
|
31
|
+
# WebSocket connection ID
|
|
32
|
+
self.connection_id = request_context.get("connectionId", "")
|
|
33
|
+
|
|
34
|
+
# These will only be available, at monst, during the on-connect step
|
|
35
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
36
|
+
headers_dict = {}
|
|
37
|
+
for key, value in event.get("headers", {}).items():
|
|
38
|
+
headers_dict[key.lower()] = str(value)
|
|
39
|
+
self.request_headers = Headers(headers_dict)
|
|
40
|
+
|
|
41
|
+
def get_client_ip(self) -> str:
|
|
42
|
+
"""Get the client IP address from WebSocket request context."""
|
|
43
|
+
request_context = self.event.get("requestContext", {})
|
|
44
|
+
identity = request_context.get("identity", {})
|
|
45
|
+
|
|
46
|
+
if "sourceIp" in identity:
|
|
47
|
+
return identity["sourceIp"]
|
|
48
|
+
|
|
49
|
+
raise ValueError("Unable to find the client ip inside the API Gateway")
|
|
50
|
+
|
|
51
|
+
def respond(self, body: Any, status_code: int = 200) -> None:
|
|
52
|
+
# since there is no response to the client, we want to raise an exception for any non-200 status code so
|
|
53
|
+
# the lambda execution itself will be marked as a failure.
|
|
54
|
+
if status_code > 299:
|
|
55
|
+
if not isinstance(body, str):
|
|
56
|
+
body = json.dumps(body)
|
|
57
|
+
raise Exception(f"Non-200 Status code returned by application: {status_code}. Response: '{body}'")
|
|
58
|
+
|
|
59
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
60
|
+
"""Provide WebSocket specific context data."""
|
|
61
|
+
request_context = self.event.get("requestContext", {})
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
**super().context_specifics(),
|
|
65
|
+
"connection_id": self.connection_id,
|
|
66
|
+
"route_key": self.route_key,
|
|
67
|
+
"stage": request_context.get("stage"),
|
|
68
|
+
"request_id": request_context.get("requestId"),
|
|
69
|
+
"api_id": request_context.get("apiId"),
|
|
70
|
+
"domain_name": request_context.get("domainName"),
|
|
71
|
+
"event_type": request_context.get("eventType"),
|
|
72
|
+
"connected_at": request_context.get("connectedAt"),
|
|
73
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
from clearskies.configs import AnyDict, String
|
|
10
|
+
from clearskies.input_outputs import InputOutput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LambdaInputOutput(InputOutput):
|
|
14
|
+
"""Base class for Lambda input/output handlers that provides common Lambda functionality."""
|
|
15
|
+
|
|
16
|
+
event = AnyDict(default={})
|
|
17
|
+
context = AnyDict(default={})
|
|
18
|
+
path = String(default="/")
|
|
19
|
+
|
|
20
|
+
_cached_body = None
|
|
21
|
+
_body_was_cached = False
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self, event: dict[str, Any], context: dict[str, Any], url: str | None = "", request_method: str | None = ""
|
|
25
|
+
):
|
|
26
|
+
# Store event and context
|
|
27
|
+
self.event = event
|
|
28
|
+
self.context = context
|
|
29
|
+
|
|
30
|
+
# Initialize the base class
|
|
31
|
+
super().__init__()
|
|
32
|
+
|
|
33
|
+
def respond(self, body: Any, status_code: int = 200) -> dict[str, Any] | None:
|
|
34
|
+
"""Create standard Lambda HTTP response format."""
|
|
35
|
+
if "content-type" not in self.response_headers:
|
|
36
|
+
self.response_headers.content_type = "application/json; charset=UTF-8"
|
|
37
|
+
|
|
38
|
+
is_base64 = False
|
|
39
|
+
|
|
40
|
+
if isinstance(body, bytes):
|
|
41
|
+
is_base64 = True
|
|
42
|
+
final_body = base64.encodebytes(body).decode("utf8")
|
|
43
|
+
elif isinstance(body, str):
|
|
44
|
+
final_body = body
|
|
45
|
+
else:
|
|
46
|
+
final_body = json.dumps(body)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"isBase64Encoded": is_base64,
|
|
50
|
+
"statusCode": status_code,
|
|
51
|
+
"headers": dict(self.response_headers),
|
|
52
|
+
"body": final_body,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def has_body(self) -> bool:
|
|
56
|
+
return bool(self.get_body())
|
|
57
|
+
|
|
58
|
+
def get_body(self) -> str:
|
|
59
|
+
"""Get request body with base64 decoding if needed."""
|
|
60
|
+
if not self._body_was_cached:
|
|
61
|
+
self._body_was_cached = True
|
|
62
|
+
self._cached_body = self.event.get("body", "")
|
|
63
|
+
if (
|
|
64
|
+
self._cached_body is not None
|
|
65
|
+
and self.event.get("isBase64Encoded", False)
|
|
66
|
+
and isinstance(self._cached_body, str)
|
|
67
|
+
):
|
|
68
|
+
self._cached_body = base64.decodebytes(self._cached_body.encode("utf-8")).decode("utf-8")
|
|
69
|
+
return self._cached_body or ""
|
|
70
|
+
|
|
71
|
+
def get_client_ip(self) -> str:
|
|
72
|
+
"""Get client IP - can be overridden by subclasses for event-specific logic."""
|
|
73
|
+
return "127.0.0.1"
|
|
74
|
+
|
|
75
|
+
def get_protocol(self) -> str:
|
|
76
|
+
"""Get protocol."""
|
|
77
|
+
# Default to HTTPS for most Lambda HTTP events
|
|
78
|
+
return "https"
|
|
79
|
+
|
|
80
|
+
def get_full_path(self) -> str:
|
|
81
|
+
"""Get full path."""
|
|
82
|
+
return self.path
|
|
83
|
+
|
|
84
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
85
|
+
"""Provide Lambda-specific context."""
|
|
86
|
+
return {
|
|
87
|
+
"event": self.event,
|
|
88
|
+
"context": self.context,
|
|
89
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.exceptions import ClientError
|
|
7
|
+
from clearskies.input_outputs import Headers
|
|
8
|
+
|
|
9
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaInvoke(lambda_input_output.LambdaInputOutput):
|
|
13
|
+
"""Direct Lambda invocation specific input/output handler."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
event: dict[str, Any],
|
|
18
|
+
context: dict[str, Any],
|
|
19
|
+
request_method: str = "",
|
|
20
|
+
url: str = "",
|
|
21
|
+
):
|
|
22
|
+
# Call parent constructor
|
|
23
|
+
super().__init__(event, context)
|
|
24
|
+
|
|
25
|
+
# Direct invocation specific initialization
|
|
26
|
+
if url:
|
|
27
|
+
self.path = url
|
|
28
|
+
else:
|
|
29
|
+
self.supports_url = True
|
|
30
|
+
if request_method:
|
|
31
|
+
self.request_method = request_method.upper()
|
|
32
|
+
else:
|
|
33
|
+
self.supports_request_method = False
|
|
34
|
+
|
|
35
|
+
# Direct invocations don't have headers
|
|
36
|
+
self.request_headers = Headers({})
|
|
37
|
+
|
|
38
|
+
def has_body(self) -> bool:
|
|
39
|
+
"""Direct invocations always have a body (the event itself)."""
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def get_body(self) -> str:
|
|
43
|
+
"""Get the entire event as the body."""
|
|
44
|
+
if isinstance(self.event, (dict, list)):
|
|
45
|
+
return json.dumps(self.event)
|
|
46
|
+
return str(self.event)
|
|
47
|
+
|
|
48
|
+
def respond(self, body: Any, status_code: int = 200) -> Any:
|
|
49
|
+
"""Return the response directly for direct invocations."""
|
|
50
|
+
if isinstance(body, bytes):
|
|
51
|
+
return body.decode("utf-8")
|
|
52
|
+
return body
|
|
53
|
+
|
|
54
|
+
def get_client_ip(self) -> str:
|
|
55
|
+
"""Direct invocations don't have client IP information."""
|
|
56
|
+
return "127.0.0.1"
|
|
57
|
+
|
|
58
|
+
def get_protocol(self) -> str:
|
|
59
|
+
"""Direct invocations don't have a protocol."""
|
|
60
|
+
return "lambda"
|
|
61
|
+
|
|
62
|
+
def get_full_path(self) -> str:
|
|
63
|
+
"""Return the configured path."""
|
|
64
|
+
return self.path
|
|
65
|
+
|
|
66
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
67
|
+
"""Provide direct invocation specific context data."""
|
|
68
|
+
return {
|
|
69
|
+
**super().context_specifics(),
|
|
70
|
+
"invocation_type": "direct",
|
|
71
|
+
"function_name": self.context.get("function_name"),
|
|
72
|
+
"function_version": self.context.get("function_version"),
|
|
73
|
+
"request_id": self.context.get("aws_request_id"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def request_data(self) -> dict[str, Any] | list[Any] | None:
|
|
78
|
+
"""Return the event directly as request data."""
|
|
79
|
+
return self.event
|
|
80
|
+
|
|
81
|
+
def json_body(
|
|
82
|
+
self, required: bool = True, allow_non_json_bodies: bool = False
|
|
83
|
+
) -> dict[str, Any] | list[Any] | None:
|
|
84
|
+
"""Get the event as JSON data."""
|
|
85
|
+
# For direct invocations, the event is already an object, not a JSON string
|
|
86
|
+
if required and not self.event:
|
|
87
|
+
raise ClientError("Request body was not valid JSON")
|
|
88
|
+
return self.event
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.exceptions import ClientError
|
|
7
|
+
from clearskies.input_outputs import Headers
|
|
8
|
+
|
|
9
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaSns(lambda_input_output.LambdaInputOutput):
|
|
13
|
+
"""SNS specific Lambda input/output handler."""
|
|
14
|
+
|
|
15
|
+
record: dict[str, Any]
|
|
16
|
+
|
|
17
|
+
def __init__(self, event: dict, context: dict[str, Any], url: str = "", request_method: str = ""):
|
|
18
|
+
# Call parent constructor
|
|
19
|
+
super().__init__(event, context)
|
|
20
|
+
|
|
21
|
+
# SNS specific initialization
|
|
22
|
+
if url:
|
|
23
|
+
self.path = url
|
|
24
|
+
else:
|
|
25
|
+
self.supports_url = False
|
|
26
|
+
if request_method:
|
|
27
|
+
self.request_method = request_method.upper()
|
|
28
|
+
else:
|
|
29
|
+
self.supports_request_method = False
|
|
30
|
+
|
|
31
|
+
# SNS events don't have headers
|
|
32
|
+
self.request_headers = Headers({})
|
|
33
|
+
|
|
34
|
+
# Extract SNS message from event
|
|
35
|
+
try:
|
|
36
|
+
record = event["Records"][0]["Sns"]["Message"]
|
|
37
|
+
self.record = json.loads(record)
|
|
38
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
39
|
+
raise ClientError(
|
|
40
|
+
"The message from AWS was not a valid SNS event with serialized JSON. "
|
|
41
|
+
"The lambda_sns context for clearskies only accepts serialized JSON."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def respond(self, body: Any, status_code: int = 200) -> None:
|
|
45
|
+
"""Respond to the client, but SNS has no client."""
|
|
46
|
+
# since there is no response to the client, we want to raise an exception for any non-200 status code so
|
|
47
|
+
# the lambda execution itself will be marked as a failure.
|
|
48
|
+
if status_code > 299:
|
|
49
|
+
if not isinstance(body, str):
|
|
50
|
+
body = json.dumps(body)
|
|
51
|
+
raise Exception(f"Non-200 Status code returned by application: {status_code}. Response: '{body}'")
|
|
52
|
+
|
|
53
|
+
def get_body(self) -> str:
|
|
54
|
+
"""Get the SNS message as a JSON string."""
|
|
55
|
+
return json.dumps(self.record) if self.record else ""
|
|
56
|
+
|
|
57
|
+
def has_body(self) -> bool:
|
|
58
|
+
"""Check if SNS message exists."""
|
|
59
|
+
return bool(self.record)
|
|
60
|
+
|
|
61
|
+
def get_client_ip(self) -> str:
|
|
62
|
+
"""SNS events don't have client IP information."""
|
|
63
|
+
return "127.0.0.1"
|
|
64
|
+
|
|
65
|
+
def get_protocol(self) -> str:
|
|
66
|
+
"""SNS events don't have a protocol."""
|
|
67
|
+
return "sns"
|
|
68
|
+
|
|
69
|
+
def get_full_path(self) -> str:
|
|
70
|
+
"""Return the configured path."""
|
|
71
|
+
return self.path
|
|
72
|
+
|
|
73
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
74
|
+
"""Provide SNS specific context data."""
|
|
75
|
+
sns_record = self.event.get("Records", [{}])[0].get("Sns", {})
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
**super().context_specifics(),
|
|
79
|
+
"message_id": sns_record.get("MessageId"),
|
|
80
|
+
"topic_arn": sns_record.get("TopicArn"),
|
|
81
|
+
"subject": sns_record.get("Subject"),
|
|
82
|
+
"timestamp": sns_record.get("Timestamp"),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def request_data(self) -> dict[str, Any] | list[Any] | None:
|
|
87
|
+
"""Return the SNS message data directly."""
|
|
88
|
+
return self.record
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies.configs import AnyDict, String
|
|
7
|
+
from clearskies.exceptions import ClientError
|
|
8
|
+
from clearskies.input_outputs import Headers
|
|
9
|
+
|
|
10
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LambdaSqsStandard(lambda_input_output.LambdaInputOutput):
|
|
14
|
+
"""SQS standard queue specific Lambda input/output handler."""
|
|
15
|
+
|
|
16
|
+
record = AnyDict(default={})
|
|
17
|
+
path = String(default="/")
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
record: dict[str, Any],
|
|
22
|
+
event: dict[str, Any],
|
|
23
|
+
context: dict[str, Any],
|
|
24
|
+
url: str = "",
|
|
25
|
+
request_method: str = "",
|
|
26
|
+
):
|
|
27
|
+
# Call parent constructor with the full event
|
|
28
|
+
super().__init__(event, context)
|
|
29
|
+
|
|
30
|
+
# Store the individual SQS record
|
|
31
|
+
self.record = record
|
|
32
|
+
# SQS specific initialization
|
|
33
|
+
if url:
|
|
34
|
+
self.path = url
|
|
35
|
+
else:
|
|
36
|
+
self.supports_url = False
|
|
37
|
+
|
|
38
|
+
if request_method:
|
|
39
|
+
self.request_method = request_method.upper()
|
|
40
|
+
else:
|
|
41
|
+
self.supports_request_method = False
|
|
42
|
+
|
|
43
|
+
# SQS events don't have headers
|
|
44
|
+
self.request_headers = Headers({})
|
|
45
|
+
|
|
46
|
+
def respond(self, body: Any, status_code: int = 200) -> None:
|
|
47
|
+
"""Respond to the client, but SQS has no client."""
|
|
48
|
+
# since there is no response to the client, we want to raise an exception for any non-200 status code so
|
|
49
|
+
# the lambda execution itself will be marked as a failure.
|
|
50
|
+
if status_code > 299:
|
|
51
|
+
if not isinstance(body, str):
|
|
52
|
+
body = json.dumps(body)
|
|
53
|
+
raise Exception(f"Non-200 Status code returned by application: {status_code}. Response: '{body}'")
|
|
54
|
+
|
|
55
|
+
def get_body(self) -> str:
|
|
56
|
+
"""Get request body with base64 decoding if needed."""
|
|
57
|
+
return self.record["body"]
|
|
58
|
+
|
|
59
|
+
def has_body(self) -> bool:
|
|
60
|
+
"""Check if SQS message has a body."""
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def get_client_ip(self) -> str:
|
|
64
|
+
"""SQS events don't have client IP information."""
|
|
65
|
+
return "127.0.0.1"
|
|
66
|
+
|
|
67
|
+
def get_protocol(self) -> str:
|
|
68
|
+
"""SQS events don't have a protocol."""
|
|
69
|
+
return "sqs"
|
|
70
|
+
|
|
71
|
+
def get_full_path(self) -> str:
|
|
72
|
+
"""Return the configured path."""
|
|
73
|
+
return self.path
|
|
74
|
+
|
|
75
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
76
|
+
"""Provide SQS specific context data."""
|
|
77
|
+
return {
|
|
78
|
+
**super().context_specifics(),
|
|
79
|
+
"message_id": self.record.get("messageId"),
|
|
80
|
+
"receipt_handle": self.record.get("receiptHandle"),
|
|
81
|
+
"source_arn": self.record.get("eventSourceARN"),
|
|
82
|
+
"sent_timestamp": self.record.get("attributes", {}).get("SentTimestamp"),
|
|
83
|
+
"approximate_receive_count": self.record.get("attributes", {}).get("ApproximateReceiveCount"),
|
|
84
|
+
"message_attributes": self.record.get("messageAttributes", {}),
|
|
85
|
+
"record": self.record,
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import actions
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_ses import SESClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.ses import SES as BaseSES
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SES(BaseSES):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSES, SES)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SESClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SES.calls is None:
|
|
22
|
+
SES.calls = []
|
|
23
|
+
utcnow = self.di.build("utcnow")
|
|
24
|
+
|
|
25
|
+
SES.calls.append(
|
|
26
|
+
{
|
|
27
|
+
"from": self.sender,
|
|
28
|
+
"to": self._resolve_destination("to", model),
|
|
29
|
+
"cc": self._resolve_destination("cc", model),
|
|
30
|
+
"bcc": self._resolve_destination("bcc", model),
|
|
31
|
+
"subject": self._resolve_subject(model, utcnow),
|
|
32
|
+
"message": self._resolve_message_as_html(model, utcnow),
|
|
33
|
+
}
|
|
34
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_sns import SNSClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.sns import SNS as BaseSNS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SNS(BaseSNS):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSNS, SNS)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SNSClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SNS.calls is None:
|
|
22
|
+
SNS.calls = []
|
|
23
|
+
|
|
24
|
+
SNS.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"TopicArn": self.get_topic_arn(model),
|
|
27
|
+
"Message": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_sqs import SQSClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.sqs import SQS as BaseSQS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQS(BaseSQS):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseSQS, SQS)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SQSClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if SQS.calls is None:
|
|
22
|
+
SQS.calls = []
|
|
23
|
+
|
|
24
|
+
SQS.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"QueueUrl": self.get_queue_url(model),
|
|
27
|
+
"MessageBody": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies import Model
|
|
6
|
+
from types_boto3_stepfunctions import SFNClient
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.actions.step_function import StepFunction as BaseStepFunction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StepFunction(BaseStepFunction):
|
|
12
|
+
calls: list[dict[str, Any]] | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def mock(cls, di):
|
|
16
|
+
cls.calls = []
|
|
17
|
+
di.mock_class(BaseStepFunction, StepFunction)
|
|
18
|
+
|
|
19
|
+
def _execute_action(self, client: SFNClient, model: Model) -> None:
|
|
20
|
+
"""Send a notification as configured."""
|
|
21
|
+
if StepFunction.calls is None:
|
|
22
|
+
StepFunction.calls = []
|
|
23
|
+
|
|
24
|
+
StepFunction.calls.append(
|
|
25
|
+
{
|
|
26
|
+
"stateMachineArn": self.get_arn(model),
|
|
27
|
+
"input": self.get_message_body(model),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if self.column_to_store_execution_arn:
|
|
32
|
+
model.save({self.column_to_store_execution_arn: "mock_execution_arn"})
|