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.
Files changed (64) hide show
  1. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +1 -1
  2. clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
  3. clearskies_aws/__init__.py +15 -0
  4. clearskies_aws/actions/__init__.py +15 -0
  5. clearskies_aws/actions/action_aws.py +135 -0
  6. clearskies_aws/actions/assume_role.py +115 -0
  7. clearskies_aws/actions/ses.py +203 -0
  8. clearskies_aws/actions/sns.py +61 -0
  9. clearskies_aws/actions/sqs.py +81 -0
  10. clearskies_aws/actions/step_function.py +73 -0
  11. clearskies_aws/backends/__init__.py +19 -0
  12. clearskies_aws/backends/backend.py +106 -0
  13. clearskies_aws/backends/dynamo_db_backend.py +609 -0
  14. clearskies_aws/backends/dynamo_db_condition_parser.py +325 -0
  15. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +965 -0
  16. clearskies_aws/backends/sqs_backend.py +61 -0
  17. clearskies_aws/configs/__init__.py +0 -0
  18. clearskies_aws/contexts/__init__.py +23 -0
  19. clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
  20. clearskies_aws/contexts/lambda_alb.py +76 -0
  21. clearskies_aws/contexts/lambda_api_gateway.py +77 -0
  22. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +57 -0
  23. clearskies_aws/contexts/lambda_invocation.py +19 -0
  24. clearskies_aws/contexts/lambda_sns.py +18 -0
  25. clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +29 -0
  26. clearskies_aws/di/__init__.py +6 -0
  27. clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
  28. clearskies_aws/di/inject/__init__.py +6 -0
  29. clearskies_aws/di/inject/boto3.py +15 -0
  30. clearskies_aws/di/inject/boto3_session.py +13 -0
  31. clearskies_aws/di/inject/parameter_store.py +15 -0
  32. clearskies_aws/endpoints/__init__.py +2 -0
  33. clearskies_aws/endpoints/secrets_manager_rotation.py +195 -0
  34. clearskies_aws/endpoints/simple_body_routing.py +41 -0
  35. clearskies_aws/input_outputs/__init__.py +21 -0
  36. clearskies_aws/input_outputs/cli_web_socket_mock.py +18 -0
  37. clearskies_aws/input_outputs/lambda_alb.py +53 -0
  38. clearskies_aws/input_outputs/lambda_api_gateway.py +123 -0
  39. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +71 -0
  40. clearskies_aws/input_outputs/lambda_input_output.py +87 -0
  41. clearskies_aws/input_outputs/lambda_invocation.py +85 -0
  42. clearskies_aws/input_outputs/lambda_sns.py +79 -0
  43. clearskies_aws/input_outputs/lambda_sqs_standard.py +84 -0
  44. clearskies_aws/mocks/__init__.py +1 -0
  45. clearskies_aws/mocks/actions/__init__.py +6 -0
  46. clearskies_aws/mocks/actions/ses.py +34 -0
  47. clearskies_aws/mocks/actions/sns.py +29 -0
  48. clearskies_aws/mocks/actions/sqs.py +29 -0
  49. clearskies_aws/mocks/actions/step_function.py +32 -0
  50. clearskies_aws/models/__init__.py +0 -0
  51. clearskies_aws/models/web_socket_connection_model.py +182 -0
  52. clearskies_aws/secrets/__init__.py +13 -0
  53. clearskies_aws/secrets/additional_configs/__init__.py +62 -0
  54. clearskies_aws/secrets/additional_configs/iam_db_auth.py +39 -0
  55. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +96 -0
  56. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +80 -0
  57. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +162 -0
  58. clearskies_aws/secrets/akeyless_with_ssm_cache.py +60 -0
  59. clearskies_aws/secrets/parameter_store.py +52 -0
  60. clearskies_aws/secrets/secrets.py +16 -0
  61. clearskies_aws/secrets/secrets_manager.py +96 -0
  62. clear_skies_aws-2.0.1.dist-info/RECORD +0 -4
  63. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +0 -0
  64. {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
+ }