clear-skies-aws 2.0.1__py3-none-any.whl → 2.0.2__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.2.dist-info}/METADATA +1 -1
- clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
- clearskies_aws/__init__.py +15 -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 +19 -0
- clearskies_aws/contexts/lambda_alb.py +76 -0
- clearskies_aws/contexts/lambda_api_gateway.py +77 -0
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +57 -0
- clearskies_aws/contexts/lambda_invocation.py +19 -0
- clearskies_aws/contexts/lambda_sns.py +18 -0
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +29 -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 +2 -0
- clearskies_aws/endpoints/secrets_manager_rotation.py +195 -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 +18 -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 +71 -0
- clearskies_aws/input_outputs/lambda_input_output.py +87 -0
- clearskies_aws/input_outputs/lambda_invocation.py +85 -0
- clearskies_aws/input_outputs/lambda_sns.py +79 -0
- clearskies_aws/input_outputs/lambda_sqs_standard.py +84 -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 +0 -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.2.dist-info}/WHEEL +0 -0
- {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import botocore
|
|
8
|
+
import clearskies
|
|
9
|
+
from clearskies import Endpoint
|
|
10
|
+
from clearskies.configs import Callable as CallableConfig
|
|
11
|
+
from clearskies.configs import Schema as SchemaConfig
|
|
12
|
+
from clearskies.configs import StringList
|
|
13
|
+
from clearskies.decorators import parameters_to_properties
|
|
14
|
+
from clearskies.di.inject import Di
|
|
15
|
+
from clearskies.exceptions import ClientError
|
|
16
|
+
from clearskies.input_outputs import InputOutput
|
|
17
|
+
|
|
18
|
+
from clearskies_aws.di import inject
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecretsManagerRotation(Endpoint):
|
|
22
|
+
|
|
23
|
+
di = Di()
|
|
24
|
+
boto3 = inject.Boto3()
|
|
25
|
+
|
|
26
|
+
current = "AWSCURRENT"
|
|
27
|
+
pending = "AWSPENDING"
|
|
28
|
+
|
|
29
|
+
steps = StringList(default=["createSecret", "setSecret", "testSecret", "finishSecret"])
|
|
30
|
+
create_secret = CallableConfig(default=None)
|
|
31
|
+
set_secret = CallableConfig(default=None)
|
|
32
|
+
test_secret = CallableConfig(default=None)
|
|
33
|
+
finish_secret = CallableConfig(default=None)
|
|
34
|
+
schema = SchemaConfig(default=None)
|
|
35
|
+
|
|
36
|
+
@parameters_to_properties
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
steps: list[str] | None,
|
|
40
|
+
create_secret: Callable | None,
|
|
41
|
+
set_secret: Callable | None,
|
|
42
|
+
test_secret: Callable | None,
|
|
43
|
+
finish_secret: Callable | None,
|
|
44
|
+
schema: list[dict] | None,
|
|
45
|
+
):
|
|
46
|
+
super().__init__()
|
|
47
|
+
|
|
48
|
+
def configure(self):
|
|
49
|
+
self.finalize_and_validate_configuration()
|
|
50
|
+
class_name = self.__class__.__name__
|
|
51
|
+
if not self.create_secret:
|
|
52
|
+
raise KeyError(f"Missing required configuration 'createSecret' for handler {class_name}")
|
|
53
|
+
|
|
54
|
+
for config_name in self.steps:
|
|
55
|
+
config = getattr(self, config_name)
|
|
56
|
+
if config is None:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
def handle(self, input_output: InputOutput):
|
|
60
|
+
request_data = json.loads(input_output.get_body())
|
|
61
|
+
|
|
62
|
+
self.find_input_errors(request_data, input_output, self.schema)
|
|
63
|
+
|
|
64
|
+
arn = request_data.get("SecretId")
|
|
65
|
+
request_token = request_data.get("ClientRequestToken")
|
|
66
|
+
step = request_data.get("Step")
|
|
67
|
+
secretsmanager = self.boto3.client("secretsmanager")
|
|
68
|
+
metadata = secretsmanager.describe_secret(SecretId=arn)
|
|
69
|
+
|
|
70
|
+
self._validate_secret_and_request(step, arn, metadata, request_token)
|
|
71
|
+
|
|
72
|
+
current_secret_data = {}
|
|
73
|
+
pending_secret_data = {}
|
|
74
|
+
|
|
75
|
+
current_secret = secretsmanager.get_secret_value(SecretId=arn, VersionStage=self.current)
|
|
76
|
+
current_secret_data = json.loads(current_secret["SecretString"])
|
|
77
|
+
|
|
78
|
+
# validate the current secret
|
|
79
|
+
secret_errors = {
|
|
80
|
+
**self._extra_column_errors(current_secret_data),
|
|
81
|
+
**self._find_input_errors(current_secret_data),
|
|
82
|
+
}
|
|
83
|
+
if secret_errors:
|
|
84
|
+
raise ValueError(f"The current secret did not match the configured schema: {secret_errors}")
|
|
85
|
+
|
|
86
|
+
# check for a pending secret. Note that this is not always available. In the event that we are retrying a failed
|
|
87
|
+
# rotation it will already be set, in which case we need to skip the createSecret step.
|
|
88
|
+
try:
|
|
89
|
+
pending_secret = secretsmanager.get_secret_value(
|
|
90
|
+
SecretId=arn, VersionId=request_token, VersionStage=self.pending
|
|
91
|
+
)
|
|
92
|
+
pending_secret_data = json.loads(pending_secret["SecretString"])
|
|
93
|
+
except botocore.exceptions.ClientError as error:
|
|
94
|
+
if error.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
95
|
+
pending_secret_data = None
|
|
96
|
+
else:
|
|
97
|
+
raise error
|
|
98
|
+
|
|
99
|
+
# we can't call the createSecret step if we already have a pending secret or this will generate an error from AWS.
|
|
100
|
+
if step == "createSecret" and pending_secret_data is not None:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# call the appropriate step and pass along *everything*.
|
|
104
|
+
getattr(self, step)(
|
|
105
|
+
current_secret_data=current_secret_data,
|
|
106
|
+
pending_secret_data=pending_secret_data,
|
|
107
|
+
secretsmanager=secretsmanager,
|
|
108
|
+
metadata=metadata,
|
|
109
|
+
request_token=request_token,
|
|
110
|
+
arn=arn,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _validate_secret_and_request(self, step: str, arn: str, metadata: dict[str, Any], request_token: str):
|
|
114
|
+
"""Perform basic checks suggested by AWS of both the request and the secret to ensure validity."""
|
|
115
|
+
if step not in self.steps:
|
|
116
|
+
raise ClientError(f"Invalid step: {step}")
|
|
117
|
+
|
|
118
|
+
if not metadata.get("RotationEnabled"):
|
|
119
|
+
raise ValueError("Secret %s is not enabled for rotation" % arn)
|
|
120
|
+
|
|
121
|
+
versions = metadata["VersionIdsToStages"]
|
|
122
|
+
prefix = f"Rotation config error for version '{request_token}' of secret '{arn}': "
|
|
123
|
+
if request_token not in versions:
|
|
124
|
+
raise ValueError(f"{prefix} we don't have a stage for rotation")
|
|
125
|
+
if self.current in versions[request_token]:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice."
|
|
128
|
+
)
|
|
129
|
+
elif self.pending not in versions[request_token]:
|
|
130
|
+
raise ValueError(f"{prefix} it hasn't been set to pending yet, which makes no sense!")
|
|
131
|
+
|
|
132
|
+
def createSecret(self, **kwargs):
|
|
133
|
+
new_secret_data = self.di.call_function(self.create_secret, **kwargs)
|
|
134
|
+
if new_secret_data is None:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"I called the configured createSecret function but it didn't return anything. It has to return the new secret data."
|
|
137
|
+
)
|
|
138
|
+
if not isinstance(new_secret_data, dict):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"I called the configured createSecret function but it didn't return a dictionary. The createSecret function must return a dictionary."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
secret_errors = {
|
|
144
|
+
**self._extra_column_errors(new_secret_data),
|
|
145
|
+
**self.find_input_errors(new_secret_data),
|
|
146
|
+
}
|
|
147
|
+
if secret_errors:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"The secret data returned by the call to createSecret did not match the configured schema: {secret_errors}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# if we get this far we can store the new data
|
|
153
|
+
secretsmanager = kwargs["secretsmanager"]
|
|
154
|
+
request_token = kwargs["request_token"]
|
|
155
|
+
arn = kwargs["arn"]
|
|
156
|
+
secretsmanager.put_secret_value(
|
|
157
|
+
SecretId=arn,
|
|
158
|
+
SecretString=json.dumps(new_secret_data),
|
|
159
|
+
ClientRequestToken=request_token,
|
|
160
|
+
VersionStages=[self.pending],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def setSecret(self, **kwargs):
|
|
164
|
+
if not self._configuration.get("setSecret"):
|
|
165
|
+
return
|
|
166
|
+
self._di.call_function(self._configuration["setSecret"], **kwargs)
|
|
167
|
+
|
|
168
|
+
def testSecret(self, **kwargs):
|
|
169
|
+
if not self._configuration.get("testSecret"):
|
|
170
|
+
return
|
|
171
|
+
self._di.call_function(self._configuration["testSecret"], **kwargs)
|
|
172
|
+
|
|
173
|
+
def finishSecret(self, **kwargs):
|
|
174
|
+
if self._configuration.get("finishSecret"):
|
|
175
|
+
self._di.call_function(self._configuration["finishSecret"], **kwargs)
|
|
176
|
+
|
|
177
|
+
secretsmanager = kwargs["secretsmanager"]
|
|
178
|
+
request_token = kwargs["request_token"]
|
|
179
|
+
arn = kwargs["arn"]
|
|
180
|
+
metadata = kwargs["metadata"]
|
|
181
|
+
current_version = None
|
|
182
|
+
for version in metadata["VersionIdsToStages"]:
|
|
183
|
+
if self.current not in metadata["VersionIdsToStages"][version]:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if version == request_token:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
current_version = version
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
# finish the rotation by taking the new version and making it current.
|
|
193
|
+
secretsmanager.update_secret_version_stage(
|
|
194
|
+
SecretId=arn, VersionStage=self.current, MoveToVersionId=request_token, RemoveFromVersionId=current_version
|
|
195
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies import Endpoint
|
|
7
|
+
from clearskies.configs import AnyDict, String
|
|
8
|
+
from clearskies.di.inject import Di
|
|
9
|
+
from clearskies.input_outputs import InputOutput
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimpleBodyRouting(Endpoint):
|
|
13
|
+
di = Di()
|
|
14
|
+
|
|
15
|
+
route_key = String(default="route")
|
|
16
|
+
routes = AnyDict(default={})
|
|
17
|
+
|
|
18
|
+
def __init__(self, routes: dict[str, Any], route_key: str = "route"):
|
|
19
|
+
self.routes = routes
|
|
20
|
+
self.route_key = route_key
|
|
21
|
+
|
|
22
|
+
def handle(self, input_output: InputOutput) -> Any:
|
|
23
|
+
body = json.loads(input_output.get_body()) if input_output.has_body() else {}
|
|
24
|
+
|
|
25
|
+
if not body or not body.get(self.route_key):
|
|
26
|
+
return self.error(input_output, "Not Found", 404)
|
|
27
|
+
|
|
28
|
+
route = body[self.route_key]
|
|
29
|
+
if route not in self.routes:
|
|
30
|
+
return self.error(input_output, "Not Found", 404)
|
|
31
|
+
return input_output.respond(
|
|
32
|
+
self.di.call_function(
|
|
33
|
+
self.routes[route],
|
|
34
|
+
request_data=body,
|
|
35
|
+
**input_output.context_specifics(),
|
|
36
|
+
),
|
|
37
|
+
200,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def documentation(self):
|
|
41
|
+
return []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from clearskies_aws.input_outputs.cli_web_socket_mock import CliWebSocketMock
|
|
4
|
+
from clearskies_aws.input_outputs.lambda_alb import LambdaAlb
|
|
5
|
+
from clearskies_aws.input_outputs.lambda_api_gateway import LambdaApiGateway
|
|
6
|
+
from clearskies_aws.input_outputs.lambda_api_gateway_web_socket import (
|
|
7
|
+
LambdaApiGatewayWebSocket,
|
|
8
|
+
)
|
|
9
|
+
from clearskies_aws.input_outputs.lambda_invocation import LambdaInvocation
|
|
10
|
+
from clearskies_aws.input_outputs.lambda_sns import LambdaSns
|
|
11
|
+
from clearskies_aws.input_outputs.lambda_sqs_standard import LambdaSqsStandard
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CliWebSocketMock",
|
|
15
|
+
"LambdaApiGateway",
|
|
16
|
+
"LambdaApiGatewayWebSocket",
|
|
17
|
+
"LambdaAlb",
|
|
18
|
+
"LambdaInvocation",
|
|
19
|
+
"LambdaSns",
|
|
20
|
+
"LambdaSqsStandard",
|
|
21
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import clearskies
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliWebSocketMock(clearskies.input_outputs.Cli):
|
|
9
|
+
def context_specifics(self):
|
|
10
|
+
connection_id = json.loads(self.get_body()).get("connection_id")
|
|
11
|
+
if not connection_id:
|
|
12
|
+
raise KeyError("When using the CliWebsocketMock you must provide connection_id in the request body")
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
"event": {},
|
|
16
|
+
"context": {},
|
|
17
|
+
"connection_id": connection_id,
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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 LambdaAlb(lambda_input_output.LambdaInputOutput):
|
|
12
|
+
"""Application Load Balancer specific Lambda input/output handler."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, event: dict[str, Any], context: dict[str, Any]):
|
|
15
|
+
# Call parent constructor
|
|
16
|
+
super().__init__(event, context)
|
|
17
|
+
|
|
18
|
+
# ALB specific initialization
|
|
19
|
+
self.request_method = event.get("httpMethod", "GET").upper()
|
|
20
|
+
self.path = event.get("path", "/")
|
|
21
|
+
|
|
22
|
+
# Extract query parameters (ALB only has single value query parameters)
|
|
23
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
24
|
+
|
|
25
|
+
# Extract headers (ALB only has single value headers)
|
|
26
|
+
headers_dict = {}
|
|
27
|
+
for key, value in event.get("headers", {}).items():
|
|
28
|
+
headers_dict[key.lower()] = str(value)
|
|
29
|
+
|
|
30
|
+
self.request_headers = Headers(headers_dict)
|
|
31
|
+
|
|
32
|
+
def get_client_ip(self) -> str:
|
|
33
|
+
"""Get the client IP address from ALB headers."""
|
|
34
|
+
# ALB always provides client IP via X-Forwarded-For header
|
|
35
|
+
forwarded_for = self.request_headers.get("x-forwarded-for")
|
|
36
|
+
if not forwarded_for:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
"The x-forwarded-for header wasn't present in the request, and it should always exist for anything behind an ALB. You are probably using the wrong context."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# X-Forwarded-For can contain multiple IPs, take the first one
|
|
42
|
+
return forwarded_for.split(",")[0].strip()
|
|
43
|
+
|
|
44
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
45
|
+
"""Provide ALB specific context data."""
|
|
46
|
+
request_context = self.event.get("requestContext", {})
|
|
47
|
+
elb = request_context.get("elb", {})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
**super().context_specifics(),
|
|
51
|
+
"path": self.path,
|
|
52
|
+
"target_group_arn": elb.get("targetGroupArn"),
|
|
53
|
+
}
|
|
@@ -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,71 @@
|
|
|
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 LambdaApiGatewayWebSocket(lambda_input_output.LambdaInputOutput):
|
|
12
|
+
"""Api Gateway WebSocket specific Lambda input/output handler."""
|
|
13
|
+
|
|
14
|
+
route_key = String(default="")
|
|
15
|
+
connection_id = String(default="")
|
|
16
|
+
|
|
17
|
+
def __init__(self, event: dict[str, Any], context: dict[str, Any], url: str = ""):
|
|
18
|
+
# Call parent constructor
|
|
19
|
+
super().__init__(event, context)
|
|
20
|
+
|
|
21
|
+
self.path = url
|
|
22
|
+
|
|
23
|
+
# WebSocket specific initialization
|
|
24
|
+
request_context = event.get("requestContext", {})
|
|
25
|
+
|
|
26
|
+
# WebSocket uses route_key, but doesn't have either a route or a method
|
|
27
|
+
self.route_key = request_context.get("routeKey", "")
|
|
28
|
+
self.request_method = self.route_key.upper() # For compatibility
|
|
29
|
+
|
|
30
|
+
# WebSocket connection ID
|
|
31
|
+
self.connection_id = request_context.get("connectionId", "")
|
|
32
|
+
|
|
33
|
+
# These will only be available, at monst, during the on-connect step
|
|
34
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
35
|
+
headers_dict = {}
|
|
36
|
+
for key, value in event.get("headers", {}).items():
|
|
37
|
+
headers_dict[key.lower()] = str(value)
|
|
38
|
+
self.request_headers = Headers(headers_dict)
|
|
39
|
+
|
|
40
|
+
def get_client_ip(self) -> str:
|
|
41
|
+
"""Get the client IP address from WebSocket request context."""
|
|
42
|
+
request_context = self.event.get("requestContext", {})
|
|
43
|
+
identity = request_context.get("identity", {})
|
|
44
|
+
|
|
45
|
+
if "sourceIp" in identity:
|
|
46
|
+
return identity["sourceIp"]
|
|
47
|
+
|
|
48
|
+
raise ValueError("Unable to find the client ip inside the API Gateway")
|
|
49
|
+
|
|
50
|
+
def respond(self, body: Any, status_code: int = 200) -> dict[str, Any]:
|
|
51
|
+
"""Create WebSocket specific response format."""
|
|
52
|
+
# WebSocket responses are simpler than HTTP responses
|
|
53
|
+
return {
|
|
54
|
+
"statusCode": status_code,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
58
|
+
"""Provide WebSocket specific context data."""
|
|
59
|
+
request_context = self.event.get("requestContext", {})
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
**super().context_specifics(),
|
|
63
|
+
"connection_id": self.connection_id,
|
|
64
|
+
"route_key": self.route_key,
|
|
65
|
+
"stage": request_context.get("stage"),
|
|
66
|
+
"request_id": request_context.get("requestId"),
|
|
67
|
+
"api_id": request_context.get("apiId"),
|
|
68
|
+
"domain_name": request_context.get("domainName"),
|
|
69
|
+
"event_type": request_context.get("eventType"),
|
|
70
|
+
"connected_at": request_context.get("connectedAt"),
|
|
71
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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__(self, event: dict[str, Any], context: dict[str, Any]):
|
|
24
|
+
# Store event and context
|
|
25
|
+
self.event = event
|
|
26
|
+
self.context = context
|
|
27
|
+
|
|
28
|
+
# Initialize the base class
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
def respond(self, body: Any, status_code: int = 200) -> dict[str, Any]:
|
|
32
|
+
"""Create standard Lambda HTTP response format."""
|
|
33
|
+
if "content-type" not in self.response_headers:
|
|
34
|
+
self.response_headers.content_type = "application/json; charset=UTF-8"
|
|
35
|
+
|
|
36
|
+
is_base64 = False
|
|
37
|
+
|
|
38
|
+
if isinstance(body, bytes):
|
|
39
|
+
is_base64 = True
|
|
40
|
+
final_body = base64.encodebytes(body).decode("utf8")
|
|
41
|
+
elif isinstance(body, str):
|
|
42
|
+
final_body = body
|
|
43
|
+
else:
|
|
44
|
+
final_body = json.dumps(body)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"isBase64Encoded": is_base64,
|
|
48
|
+
"statusCode": status_code,
|
|
49
|
+
"headers": dict(self.response_headers),
|
|
50
|
+
"body": final_body,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def has_body(self) -> bool:
|
|
54
|
+
return bool(self.get_body())
|
|
55
|
+
|
|
56
|
+
def get_body(self) -> str:
|
|
57
|
+
"""Get request body with base64 decoding if needed."""
|
|
58
|
+
if not self._body_was_cached:
|
|
59
|
+
self._body_was_cached = True
|
|
60
|
+
self._cached_body = self.event.get("body", "")
|
|
61
|
+
if (
|
|
62
|
+
self._cached_body is not None
|
|
63
|
+
and self.event.get("isBase64Encoded", False)
|
|
64
|
+
and isinstance(self._cached_body, str)
|
|
65
|
+
):
|
|
66
|
+
self._cached_body = base64.decodebytes(self._cached_body.encode("utf-8")).decode("utf-8")
|
|
67
|
+
return self._cached_body or ""
|
|
68
|
+
|
|
69
|
+
def get_client_ip(self) -> str:
|
|
70
|
+
"""Get client IP - can be overridden by subclasses for event-specific logic."""
|
|
71
|
+
return "127.0.0.1"
|
|
72
|
+
|
|
73
|
+
def get_protocol(self) -> str:
|
|
74
|
+
"""Get protocol."""
|
|
75
|
+
# Default to HTTPS for most Lambda HTTP events
|
|
76
|
+
return "https"
|
|
77
|
+
|
|
78
|
+
def get_full_path(self) -> str:
|
|
79
|
+
"""Get full path."""
|
|
80
|
+
return self.path
|
|
81
|
+
|
|
82
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
83
|
+
"""Provide Lambda-specific context."""
|
|
84
|
+
return {
|
|
85
|
+
"event": self.event,
|
|
86
|
+
"context": self.context,
|
|
87
|
+
}
|