clear-skies-aws 1.10.2__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 (86) hide show
  1. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +36 -35
  2. clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
  3. {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +1 -1
  4. clear_skies_aws-2.0.2.dist-info/licenses/LICENSE +21 -0
  5. clearskies_aws/__init__.py +15 -2
  6. clearskies_aws/actions/__init__.py +13 -106
  7. clearskies_aws/actions/action_aws.py +74 -57
  8. clearskies_aws/actions/assume_role.py +43 -30
  9. clearskies_aws/actions/ses.py +82 -73
  10. clearskies_aws/actions/sns.py +27 -30
  11. clearskies_aws/actions/sqs.py +32 -33
  12. clearskies_aws/actions/step_function.py +38 -31
  13. clearskies_aws/backends/__init__.py +11 -4
  14. clearskies_aws/backends/backend.py +106 -0
  15. clearskies_aws/backends/dynamo_db_backend.py +150 -155
  16. clearskies_aws/backends/dynamo_db_condition_parser.py +40 -80
  17. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +179 -337
  18. clearskies_aws/backends/sqs_backend.py +32 -51
  19. clearskies_aws/configs/__init__.py +0 -0
  20. clearskies_aws/contexts/__init__.py +23 -10
  21. clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
  22. clearskies_aws/contexts/lambda_alb.py +76 -0
  23. clearskies_aws/contexts/lambda_api_gateway.py +75 -28
  24. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +56 -29
  25. clearskies_aws/contexts/lambda_invocation.py +15 -44
  26. clearskies_aws/contexts/lambda_sns.py +8 -33
  27. clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +14 -36
  28. clearskies_aws/di/__init__.py +6 -1
  29. clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
  30. clearskies_aws/di/inject/__init__.py +6 -0
  31. clearskies_aws/di/inject/boto3.py +15 -0
  32. clearskies_aws/di/inject/boto3_session.py +13 -0
  33. clearskies_aws/di/inject/parameter_store.py +15 -0
  34. clearskies_aws/{handlers → endpoints}/secrets_manager_rotation.py +76 -55
  35. clearskies_aws/endpoints/simple_body_routing.py +41 -0
  36. clearskies_aws/input_outputs/__init__.py +21 -8
  37. clearskies_aws/input_outputs/{cli_websocket_mock.py → cli_web_socket_mock.py} +9 -3
  38. clearskies_aws/input_outputs/lambda_alb.py +53 -0
  39. clearskies_aws/input_outputs/lambda_api_gateway.py +106 -88
  40. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +69 -6
  41. clearskies_aws/input_outputs/lambda_input_output.py +87 -0
  42. clearskies_aws/input_outputs/lambda_invocation.py +77 -26
  43. clearskies_aws/input_outputs/lambda_sns.py +66 -39
  44. clearskies_aws/input_outputs/lambda_sqs_standard.py +70 -40
  45. clearskies_aws/mocks/actions/ses.py +25 -19
  46. clearskies_aws/mocks/actions/sns.py +18 -12
  47. clearskies_aws/mocks/actions/sqs.py +18 -12
  48. clearskies_aws/mocks/actions/step_function.py +19 -13
  49. clearskies_aws/models/__init__.py +0 -0
  50. clearskies_aws/models/web_socket_connection_model.py +182 -0
  51. clearskies_aws/secrets/__init__.py +13 -7
  52. clearskies_aws/secrets/additional_configs/__init__.py +10 -2
  53. clearskies_aws/secrets/additional_configs/iam_db_auth.py +26 -16
  54. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +43 -39
  55. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +30 -31
  56. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +70 -49
  57. clearskies_aws/secrets/akeyless_with_ssm_cache.py +32 -18
  58. clearskies_aws/secrets/parameter_store.py +34 -32
  59. clearskies_aws/secrets/secrets.py +16 -0
  60. clearskies_aws/secrets/secrets_manager.py +78 -57
  61. clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
  62. clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
  63. clearskies_aws/actions/assume_role_test.py +0 -72
  64. clearskies_aws/actions/ses_test.py +0 -89
  65. clearskies_aws/actions/sns_test.py +0 -77
  66. clearskies_aws/actions/sqs_test.py +0 -127
  67. clearskies_aws/actions/step_function_test.py +0 -103
  68. clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
  69. clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
  70. clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
  71. clearskies_aws/backends/sqs_backend_test.py +0 -31
  72. clearskies_aws/contexts/cli.py +0 -19
  73. clearskies_aws/contexts/cli_websocket_mock.py +0 -33
  74. clearskies_aws/contexts/lambda_elb.py +0 -30
  75. clearskies_aws/contexts/lambda_http_gateway.py +0 -30
  76. clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
  77. clearskies_aws/contexts/wsgi.py +0 -19
  78. clearskies_aws/di/standard_dependencies.py +0 -60
  79. clearskies_aws/handlers/simple_body_routing.py +0 -39
  80. clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
  81. clearskies_aws/input_outputs/lambda_elb.py +0 -21
  82. clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
  83. clearskies_aws/secrets/parameter_store_test.py +0 -18
  84. clearskies_aws/secrets/secrets_manager_test.py +0 -18
  85. clearskies_aws/web_socket_connection_model.py +0 -43
  86. clearskies_aws/{handlers → endpoints}/__init__.py +1 -1
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from clearskies_aws.di.inject.boto3 import Boto3
4
+ from clearskies_aws.di.inject.boto3_session import Boto3Session
5
+
6
+ __all__ = ["Boto3", "Boto3Session"]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from types import ModuleType
4
+
5
+ from clearskies.di.injectable import Injectable
6
+
7
+
8
+ class Boto3(Injectable):
9
+ def __init__(self, cache: bool = True):
10
+ self.cache = cache
11
+
12
+ def __get__(self, instance, parent) -> ModuleType:
13
+ if instance is None:
14
+ return self # type: ignore
15
+ return self._di.build_from_name("boto3", cache=self.cache)
@@ -0,0 +1,13 @@
1
+ from types import ModuleType
2
+
3
+ from clearskies.di.injectable import Injectable
4
+
5
+
6
+ class Boto3Session(Injectable):
7
+ def __init__(self, cache: bool = True):
8
+ self.cache = cache
9
+
10
+ def __get__(self, instance, parent) -> ModuleType:
11
+ if instance is None:
12
+ return self # type: ignore
13
+ return self._di.build_from_name("boto3_session", cache=self.cache)
@@ -0,0 +1,15 @@
1
+ from clearskies.di.injectable import Injectable
2
+
3
+ from clearskies_aws.secrets.parameter_store import (
4
+ ParameterStore as ParameterStoreDependency,
5
+ )
6
+
7
+
8
+ class ParameterStore(Injectable):
9
+ def __init__(self, cache: bool = True):
10
+ self.cache = cache
11
+
12
+ def __get__(self, instance, parent) -> ParameterStoreDependency:
13
+ if instance is None:
14
+ return self # type: ignore
15
+ return self._di.build_from_name("parameter_store", cache=self.cache)
@@ -1,56 +1,70 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
4
+ from collections.abc import Callable
5
+ from typing import Any
2
6
 
3
- import clearskies
4
- from clearskies.handlers.exceptions import ClientError
5
- from clearskies.handlers.base import Base
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
7
17
 
8
- class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
9
- _steps = ["createSecret", "setSecret", "testSecret", "finishSecret"]
18
+ from clearskies_aws.di import inject
10
19
 
11
- current = "AWSCURRENT"
12
- pending = "AWSPENDING"
13
20
 
14
- _configuration_defaults = {
15
- "createSecret": None,
16
- "setSecret": None,
17
- "testSecret": None,
18
- "finishSecret": None,
19
- "schema": [],
20
- }
21
+ class SecretsManagerRotation(Endpoint):
21
22
 
22
- def __init__(self, boto3, di):
23
- super().__init__(di)
24
- self.boto3 = boto3
23
+ di = Di()
24
+ boto3 = inject.Boto3()
25
25
 
26
- def _check_configuration(self, configuration):
27
- super()._check_configuration(configuration)
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()
28
50
  class_name = self.__class__.__name__
29
- if not configuration.get("createSecret"):
51
+ if not self.create_secret:
30
52
  raise KeyError(f"Missing required configuration 'createSecret' for handler {class_name}")
31
53
 
32
- for config_name in self._steps:
33
- config = configuration.get(config_name)
54
+ for config_name in self.steps:
55
+ config = getattr(self, config_name)
34
56
  if config is None:
35
57
  continue
36
- if not callable(config):
37
- raise ValueError(f"Misconfiguration for handler {class_name}: configuration '{config_name}' is not callable")
38
58
 
39
- if configuration.get("schema") is not None:
40
- self._check_schema(configuration["schema"], None, f"Misconfiguration for handler {class_name}")
59
+ def handle(self, input_output: InputOutput):
60
+ request_data = json.loads(input_output.get_body())
41
61
 
42
- def _finalize_configuration(self, configuration):
43
- if configuration.get('schema'):
44
- configuration['schema'] = self._schema_to_columns(configuration['schema'])
45
- return super()._finalize_configuration(configuration)
62
+ self.find_input_errors(request_data, input_output, self.schema)
46
63
 
47
- def handle(self, input_output):
48
- request_data = input_output.json_body()
49
-
50
- arn = request_data.get('SecretId')
51
- request_token = request_data.get('ClientRequestToken')
52
- step = request_data.get('Step')
53
- secretsmanager = self.boto3.client('secretsmanager')
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")
54
68
  metadata = secretsmanager.describe_secret(SecretId=arn)
55
69
 
56
70
  self._validate_secret_and_request(step, arn, metadata, request_token)
@@ -59,7 +73,7 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
59
73
  pending_secret_data = {}
60
74
 
61
75
  current_secret = secretsmanager.get_secret_value(SecretId=arn, VersionStage=self.current)
62
- current_secret_data = json.loads(current_secret['SecretString'])
76
+ current_secret_data = json.loads(current_secret["SecretString"])
63
77
 
64
78
  # validate the current secret
65
79
  secret_errors = {
@@ -72,10 +86,12 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
72
86
  # check for a pending secret. Note that this is not always available. In the event that we are retrying a failed
73
87
  # rotation it will already be set, in which case we need to skip the createSecret step.
74
88
  try:
75
- pending_secret = secretsmanager.get_secret_value(SecretId=arn, VersionId=request_token, VersionStage=self.pending)
76
- pending_secret_data = json.loads(pending_secret['SecretString'])
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"])
77
93
  except botocore.exceptions.ClientError as error:
78
- if error.response['Error']['Code'] == 'ResourceNotFoundException':
94
+ if error.response["Error"]["Code"] == "ResourceNotFoundException":
79
95
  pending_secret_data = None
80
96
  else:
81
97
  raise error
@@ -94,12 +110,12 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
94
110
  arn=arn,
95
111
  )
96
112
 
97
- def _validate_secret_and_request(self, step, arn, metadata, request_token):
98
- """ This function does some basic checks suggested by AWS of both the request and the secret to make sure everything is on the up-and-up. """
99
- if step not in self._steps:
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:
100
116
  raise ClientError(f"Invalid step: {step}")
101
117
 
102
- if not metadata.get('RotationEnabled'):
118
+ if not metadata.get("RotationEnabled"):
103
119
  raise ValueError("Secret %s is not enabled for rotation" % arn)
104
120
 
105
121
  versions = metadata["VersionIdsToStages"]
@@ -107,23 +123,31 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
107
123
  if request_token not in versions:
108
124
  raise ValueError(f"{prefix} we don't have a stage for rotation")
109
125
  if self.current in versions[request_token]:
110
- raise ValueError(f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice.")
126
+ raise ValueError(
127
+ f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice."
128
+ )
111
129
  elif self.pending not in versions[request_token]:
112
130
  raise ValueError(f"{prefix} it hasn't been set to pending yet, which makes no sense!")
113
131
 
114
132
  def createSecret(self, **kwargs):
115
- new_secret_data = self._di.call_function(self._configuration["createSecret"], **kwargs)
133
+ new_secret_data = self.di.call_function(self.create_secret, **kwargs)
116
134
  if new_secret_data is None:
117
- raise ValueError(f"I called the configured createSecret function but it didn't return anything. It has to return the new secret data.")
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
+ )
118
138
  if not isinstance(new_secret_data, dict):
119
- raise ValueError(f"I called the configured createSecret function but it didn't return a dictionary. The createSecret function must return a dictionary.")
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
+ )
120
142
 
121
143
  secret_errors = {
122
144
  **self._extra_column_errors(new_secret_data),
123
- **self._find_input_errors(new_secret_data),
145
+ **self.find_input_errors(new_secret_data),
124
146
  }
125
147
  if secret_errors:
126
- raise ValueError(f"The secret data returned by the call to createSecret did not match the configured schema: {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
+ )
127
151
 
128
152
  # if we get this far we can store the new data
129
153
  secretsmanager = kwargs["secretsmanager"]
@@ -167,8 +191,5 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
167
191
 
168
192
  # finish the rotation by taking the new version and making it current.
169
193
  secretsmanager.update_secret_version_stage(
170
- SecretId=arn,
171
- VersionStage=self.current,
172
- MoveToVersionId=request_token,
173
- RemoveFromVersionId=current_version
194
+ SecretId=arn, VersionStage=self.current, MoveToVersionId=request_token, RemoveFromVersionId=current_version
174
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 []
@@ -1,8 +1,21 @@
1
- from .cli_websocket_mock import CLIWebsocketMock
2
- from .lambda_api_gateway import LambdaAPIGateway
3
- from .lambda_api_gateway_web_socket import LambdaAPIGatewayWebSocket
4
- from .lambda_elb import LambdaELB
5
- from .lambda_http_gateway import LambdaHTTPGateway
6
- from .lambda_invocation import LambdaInvocation
7
- from .lambda_sqs_standard import LambdaSqsStandard
8
- from .lambda_sns import LambdaSns
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
+ ]
@@ -1,9 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
1
5
  import clearskies
2
- class CLIWebsocketMock(clearskies.input_outputs.CLI):
6
+
7
+
8
+ class CliWebSocketMock(clearskies.input_outputs.Cli):
3
9
  def context_specifics(self):
4
- connection_id = self.json_body().get("connection_id")
10
+ connection_id = json.loads(self.get_body()).get("connection_id")
5
11
  if not connection_id:
6
- raise KeyError("When using the CLIWebsocketMock you must provide connection_id in the request body")
12
+ raise KeyError("When using the CliWebsocketMock you must provide connection_id in the request body")
7
13
 
8
14
  return {
9
15
  "event": {},
@@ -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
+ }
@@ -1,105 +1,123 @@
1
- from clearskies.input_outputs.input_output import InputOutput
2
- import json
3
- import base64
4
- import urllib
5
- class LambdaAPIGateway(InputOutput):
6
- _event = None
7
- _context = None
8
- _request_headers = None
9
- _request_method = None
10
- _path = None
11
- _resource = None
12
- _query_parameters = None
13
- _path_parameters = None
14
- _cached_body = None
15
- _body_was_cached = False
16
-
17
- def __init__(self, event, context):
18
- self._event = event
19
- self._context = context
20
- self._request_method = event.get('httpMethod', '').upper()
21
- self._path = event.get('path')
22
- self._resource = event.get('resource')
23
- self._query_parameters = event.get('queryStringParameters', {})
24
- self._path_parameters = event.get('pathParameters', {})
25
- self._request_headers = {}
26
- for (key, value) in event.get('headers', {}).items():
27
- self._request_headers[key.lower()] = value
28
-
29
- def respond(self, body, status_code=200):
30
- if not self.has_header('content-type'):
31
- self.set_header('content-type', 'application/json; charset=UTF-8')
32
-
33
- is_base64 = False
34
- if type(body) == bytes:
35
- is_base64 = True
36
- final_body = base64.encodebytes(body).decode('utf8')
37
- elif type(body) == str:
38
- final_body = body
39
- else:
40
- final_body = json.dumps(body)
1
+ from __future__ import annotations
41
2
 
42
- return {
43
- "isBase64Encoded": is_base64,
44
- "statusCode": status_code,
45
- "headers": self._response_headers,
46
- "body": final_body,
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 {}),
47
50
  }
48
51
 
49
- def has_body(self):
50
- return bool(self.get_body())
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)
51
59
 
52
- def get_body(self):
53
- if not self._body_was_cached:
54
- self._cached_body = self._event.get('body')
55
- if self._cached_body is not None and self._event.get('isBase64Encoded'):
56
- self._cached_body = base64.decodebytes(self._cached_body.encode('utf-8')).decode('utf-8')
57
- return self._cached_body
60
+ self.request_headers = Headers(headers_dict)
58
61
 
59
- def get_request_method(self):
60
- return self._request_method
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", {})
61
66
 
62
- def get_script_name(self):
63
- return ''
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 = ""
64
71
 
65
- def get_path_info(self):
66
- return self._path
72
+ # Extract query parameters (v2 only has single values)
73
+ self.query_parameters = event.get("queryStringParameters") or {}
67
74
 
68
- def get_query_string(self):
69
- return urllib.parse.urlencode(self._query_parameters)
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)
70
79
 
71
- def get_content_type(self):
72
- return self.get_request_header('content-type', True)
80
+ self.request_headers = Headers(headers_dict)
73
81
 
74
- def get_protocol(self):
75
- return 'https'
82
+ def get_client_ip(self) -> str:
83
+ """Get the client IP address from API Gateway event."""
84
+ request_context = self.event.get("requestContext", {})
76
85
 
77
- def has_request_header(self, header_name):
78
- return header_name.lower() in self._request_headers
86
+ # Try v1 format first (identity.sourceIp)
87
+ identity = request_context.get("identity", {})
88
+ if "sourceIp" in identity:
89
+ return identity["sourceIp"]
79
90
 
80
- def get_request_header(self, header_name, silent=False):
81
- if not header_name.lower() in self._request_headers:
82
- if not silent:
83
- raise KeyError(f"HTTP header '{header_name}' was not found in request")
84
- return ''
85
- return self._request_headers[header_name.lower()]
91
+ # Try v2 format (http.sourceIp)
92
+ http_context = request_context.get("http", {})
93
+ if "sourceIp" in http_context:
94
+ return http_context["sourceIp"]
86
95
 
87
- def get_query_parameter(self, key):
88
- return self._query_parameters[key] if key in self._query_parameters else []
96
+ raise ValueError("Unable to find the client ip inside the API Gateway")
89
97
 
90
- def get_query_parameters(self):
91
- return self._query_parameters
98
+ def get_protocol(self) -> str:
99
+ """Get the protocol from API Gateway request context."""
100
+ request_context = self.event.get("requestContext", {})
92
101
 
93
- def context_specifics(self):
94
- return {
95
- "event": self._event,
96
- "context": self._context,
97
- }
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"
98
110
 
99
- def get_client_ip(self):
100
- # I haven't actually tested with an API gateway yet to figure out which of these works...
101
- sourceIp = self._event.get('requestContext', {}).get('identity', {}).get('sourceIp')
102
- if sourceIp:
103
- return sourceIp
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", {})
104
115
 
105
- return self.get_request_header('x-forwarded-for', silent=True)
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
+ }