lambda-api-decorators-cdk 0.2.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.
- lambda_api_decorators_cdk/__init__.py +5 -0
- lambda_api_decorators_cdk/api_type.py +8 -0
- lambda_api_decorators_cdk/ast_helper.py +361 -0
- lambda_api_decorators_cdk/lambda_api.py +85 -0
- lambda_api_decorators_cdk/lambda_api_config.py +151 -0
- lambda_api_decorators_cdk/resource_builder.py +794 -0
- lambda_api_decorators_cdk/source_layout.py +8 -0
- lambda_api_decorators_cdk-0.2.3.dist-info/METADATA +144 -0
- lambda_api_decorators_cdk-0.2.3.dist-info/RECORD +12 -0
- lambda_api_decorators_cdk-0.2.3.dist-info/WHEEL +5 -0
- lambda_api_decorators_cdk-0.2.3.dist-info/licenses/LICENSE +21 -0
- lambda_api_decorators_cdk-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class DecoratorInvocation:
|
|
9
|
+
"""A single decorator call captured without interpreting its purpose."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
args: Tuple[Any, ...]
|
|
13
|
+
kwargs: Tuple[Tuple[str, Any], ...]
|
|
14
|
+
|
|
15
|
+
class Method:
|
|
16
|
+
ALLOWED_METHODS = {'PUT', 'POST', 'GET', 'DELETE', 'ANY'}
|
|
17
|
+
|
|
18
|
+
def __init__(self, path_to_file:str, file: str, handler: str, method: str, decorators):
|
|
19
|
+
'''One http method is always associated with a file:handler entrypoint in a one-to-one relationship and each handler has decorators in a one-to-many relationship'''
|
|
20
|
+
if method not in self.ALLOWED_METHODS:
|
|
21
|
+
raise ValueError(f"Invalid method: {method}. Allowed methods are {', '.join(self.ALLOWED_METHODS)}")
|
|
22
|
+
self.method = method
|
|
23
|
+
self.file = file.replace(os.sep, '/')
|
|
24
|
+
self.path_to_file = path_to_file.replace(os.sep, '/')
|
|
25
|
+
self.handler = handler
|
|
26
|
+
if isinstance(decorators, dict):
|
|
27
|
+
decorators = tuple(
|
|
28
|
+
DecoratorInvocation(name, (value,), ())
|
|
29
|
+
for name, value in decorators.items()
|
|
30
|
+
)
|
|
31
|
+
self._decorator_invocations = tuple(decorators)
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return (self.get_method(), self.get_file(), self.get_handler())
|
|
35
|
+
|
|
36
|
+
def get_method(self):
|
|
37
|
+
return self.method
|
|
38
|
+
|
|
39
|
+
def get_logical_id(self):
|
|
40
|
+
return self.get_file().replace('.','dot').replace('/','-') + '-' + self.get_handler()
|
|
41
|
+
|
|
42
|
+
def get_file(self):
|
|
43
|
+
return self.file
|
|
44
|
+
|
|
45
|
+
def get_handler(self):
|
|
46
|
+
return self.handler
|
|
47
|
+
|
|
48
|
+
def get_path_to_file(self):
|
|
49
|
+
return self.path_to_file
|
|
50
|
+
|
|
51
|
+
def get_method(self):
|
|
52
|
+
return self.method
|
|
53
|
+
|
|
54
|
+
def get_decorator_invocations(self):
|
|
55
|
+
return self._decorator_invocations
|
|
56
|
+
|
|
57
|
+
def __eq__(self, other):
|
|
58
|
+
try:
|
|
59
|
+
check = self.get_method() == other.get_method and self.get_handler() == other.get_handler()
|
|
60
|
+
if check:
|
|
61
|
+
raise ValueError(f'{self.file()} and {other.get_file()} define the same method and handler, implementation may vary')
|
|
62
|
+
return isinstance(other, Method) and check
|
|
63
|
+
except TypeError:
|
|
64
|
+
raise TypeError
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Resource:
|
|
68
|
+
def __init__(self, path = '/'):
|
|
69
|
+
self.path = path
|
|
70
|
+
self.methods = []
|
|
71
|
+
self.connections = []
|
|
72
|
+
|
|
73
|
+
def get_path(self) -> str:
|
|
74
|
+
return self.path
|
|
75
|
+
|
|
76
|
+
def get_methods(self) -> list[Method]:
|
|
77
|
+
return self.methods
|
|
78
|
+
|
|
79
|
+
def get_connections(self) -> list['Resource']:
|
|
80
|
+
return self.connections
|
|
81
|
+
|
|
82
|
+
def __eq__(self, other):
|
|
83
|
+
return isinstance(other, Resource) and self.get_path() == other.get_path()
|
|
84
|
+
|
|
85
|
+
def __str__(self):
|
|
86
|
+
methods_str = ' '
|
|
87
|
+
for method in self.get_methods():
|
|
88
|
+
methods_str = '(' + str(method.__str__()) + ') '
|
|
89
|
+
#methods_str = methods_str + '(' + method.get_method() + ' at ' + method.get_logical_id() + ') '
|
|
90
|
+
return self.get_path() + methods_str
|
|
91
|
+
|
|
92
|
+
def add_method(self, method: Method) -> bool:
|
|
93
|
+
'''Aggregate Method to present Resource's endpoint'''
|
|
94
|
+
if method not in self.methods:
|
|
95
|
+
self.methods.append(method)
|
|
96
|
+
return True
|
|
97
|
+
else:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def add_methods(self, method: list[Method]) -> bool:
|
|
101
|
+
'''Aggregate Methods to present Resource's endpoint'''
|
|
102
|
+
for method in self.methods:
|
|
103
|
+
if method not in self.methods:
|
|
104
|
+
self.methods.append(method)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def includes_path(self, sub_path) -> bool:
|
|
108
|
+
'''Will indicate if another's Resource path is a ramification of the present Resource'''
|
|
109
|
+
return self.path == '/' or sub_path == self.path or sub_path.startswith(self.path.rstrip('/') + '/')
|
|
110
|
+
|
|
111
|
+
def connect(self, resource: 'Resource') -> bool:
|
|
112
|
+
if self.includes_path(resource.get_path()) and resource not in self.get_connections():
|
|
113
|
+
self.connections.append(resource)
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def get_matching_prefix_index(self, ext_path: str) -> int:
|
|
118
|
+
current_path = self.get_path()
|
|
119
|
+
matching_prefix_index = ext_path.index(current_path) + len(current_path) if current_path in ext_path else -1
|
|
120
|
+
return matching_prefix_index
|
|
121
|
+
#We should always have a longest prefix match index at 1 because '/'
|
|
122
|
+
|
|
123
|
+
def clone(self) -> 'Resource':
|
|
124
|
+
clone = Resource(self.get_path())
|
|
125
|
+
clone.connections = self.get_connections()
|
|
126
|
+
clone.methods = self.get_methods()
|
|
127
|
+
return clone
|
|
128
|
+
|
|
129
|
+
def switch_nodes(self, resource:'Resource'):
|
|
130
|
+
aux = self.clone()
|
|
131
|
+
self.methods = resource.get_methods()
|
|
132
|
+
self.connections = resource.get_connections()
|
|
133
|
+
self.path = resource.get_path()
|
|
134
|
+
resource.connect(aux)
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
def insert_node(self, resource: 'Resource') -> bool:
|
|
138
|
+
resource_path = resource.get_path()
|
|
139
|
+
|
|
140
|
+
#Edge cases: Resource refers to same endpoint / Resource comes before / Resource goes deeper or next to current node as bifurcation
|
|
141
|
+
if resource_path == self.get_path(): #They have the same path, new resource comes from a different function/file so we merge
|
|
142
|
+
for method in resource.get_methods():
|
|
143
|
+
self.add_method(method)
|
|
144
|
+
for connection in resource.get_connections():
|
|
145
|
+
self.connect(connection)
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
if len(resource_path) < len(self.get_path()) and resource.includes_path(self.get_path()): #Given resource comes before current, so we have to switch them
|
|
149
|
+
# aux = self.clone()
|
|
150
|
+
# self.methods = resource.get_methods()
|
|
151
|
+
# self.connections = resource.get_connections()
|
|
152
|
+
# self.path = resource.get_path()
|
|
153
|
+
# resource.connect(aux)
|
|
154
|
+
# return True
|
|
155
|
+
return self.switch_nodes(resource)
|
|
156
|
+
|
|
157
|
+
if len(resource_path) >= len(self.get_path()) and self.includes_path(resource_path): #Resource goes deeper or bifurcation
|
|
158
|
+
if len(self.get_connections()) < 1:
|
|
159
|
+
self.connect(resource)
|
|
160
|
+
return True
|
|
161
|
+
else:
|
|
162
|
+
matching_node = self
|
|
163
|
+
matching_prefix_index = self.get_matching_prefix_index(resource_path)
|
|
164
|
+
for node in self.get_connections(): # Check if it goes deeper or may come in between two nodes
|
|
165
|
+
if node.includes_path(resource_path): #deeper candidate
|
|
166
|
+
node_matching_index = node.get_matching_prefix_index(resource_path)
|
|
167
|
+
if node_matching_index > matching_prefix_index:
|
|
168
|
+
matching_prefix_index = node_matching_index
|
|
169
|
+
matching_node = node
|
|
170
|
+
elif resource.includes_path(node.get_path()): #It comes in between, so we have to switch them or guess if it goes deeper
|
|
171
|
+
return node.insert_node(resource)
|
|
172
|
+
if matching_node.includes_path(resource_path) and matching_node.get_path() != self.get_path(): #Goes deeper/recursion
|
|
173
|
+
return matching_node.insert_node(resource)
|
|
174
|
+
else: #Bifurcation
|
|
175
|
+
self.connect(resource)
|
|
176
|
+
return True
|
|
177
|
+
else: #We should never get to this case because the root path would be '/' so we always have a startswith match in 3rd case for bifurcation
|
|
178
|
+
self.connect(resource)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def parse_file(file_path):
|
|
182
|
+
with open(file_path, 'r') as file:
|
|
183
|
+
source_code = file.read()
|
|
184
|
+
return ast.parse(source_code, filename=file_path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _literal_value(node, decorator_name):
|
|
188
|
+
try:
|
|
189
|
+
return ast.literal_eval(node)
|
|
190
|
+
except (ValueError, TypeError) as error:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"{decorator_name} only supports literal decorator values"
|
|
193
|
+
) from error
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _require_non_empty_string(value, decorator_name, field):
|
|
197
|
+
if not isinstance(value, str) or not value.strip():
|
|
198
|
+
raise ValueError(f"{decorator_name} {field} must be a non-empty string")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_grant(invocation, physical_field):
|
|
202
|
+
name = invocation.name
|
|
203
|
+
kwargs = dict(invocation.kwargs)
|
|
204
|
+
allowed_fields = {"resource_key", physical_field, "access"}
|
|
205
|
+
unexpected = next((key for key in kwargs if key not in allowed_fields), None)
|
|
206
|
+
if unexpected is not None:
|
|
207
|
+
raise ValueError(f"{name} received unexpected field {unexpected}")
|
|
208
|
+
|
|
209
|
+
if invocation.args:
|
|
210
|
+
if invocation.kwargs or len(invocation.args) != 2:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"{name} positional form requires resource_key and access"
|
|
213
|
+
)
|
|
214
|
+
resource_key, access = invocation.args
|
|
215
|
+
_require_non_empty_string(resource_key, name, "resource_key")
|
|
216
|
+
else:
|
|
217
|
+
has_resource_key = "resource_key" in kwargs
|
|
218
|
+
has_physical_name = physical_field in kwargs
|
|
219
|
+
if has_resource_key == has_physical_name:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"{name} requires exactly one of resource_key or {physical_field}"
|
|
222
|
+
)
|
|
223
|
+
address_field = "resource_key" if has_resource_key else physical_field
|
|
224
|
+
_require_non_empty_string(kwargs[address_field], name, address_field)
|
|
225
|
+
if "access" not in kwargs:
|
|
226
|
+
raise ValueError(f"{name} requires access")
|
|
227
|
+
access = kwargs["access"]
|
|
228
|
+
|
|
229
|
+
if access not in ("read", "write"):
|
|
230
|
+
raise ValueError(f"{name} access must be read or write, not {access!r}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_permission(invocation):
|
|
234
|
+
if invocation.args:
|
|
235
|
+
raise ValueError("permission only accepts keyword arguments")
|
|
236
|
+
kwargs = dict(invocation.kwargs)
|
|
237
|
+
for key in kwargs:
|
|
238
|
+
if key not in ("actions", "resources"):
|
|
239
|
+
raise ValueError(f"permission received unexpected field {key}")
|
|
240
|
+
for field in ("actions", "resources"):
|
|
241
|
+
if field not in kwargs:
|
|
242
|
+
raise ValueError(f"permission requires {field}")
|
|
243
|
+
values = kwargs[field]
|
|
244
|
+
if not isinstance(values, (list, tuple)) or not values:
|
|
245
|
+
raise ValueError(f"permission {field} must be a non-empty sequence")
|
|
246
|
+
if any(not isinstance(value, str) or not value.strip() for value in values):
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"permission {field} must contain only non-empty strings"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
normalized = tuple(
|
|
252
|
+
(key, tuple(value) if key in ("actions", "resources") else value)
|
|
253
|
+
for key, value in invocation.kwargs
|
|
254
|
+
)
|
|
255
|
+
return DecoratorInvocation(invocation.name, invocation.args, normalized)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_invocation(decorator):
|
|
259
|
+
if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Name):
|
|
260
|
+
return None
|
|
261
|
+
name = decorator.func.id
|
|
262
|
+
if any(keyword.arg is None for keyword in decorator.keywords):
|
|
263
|
+
raise ValueError(f"{name} does not support expanded keyword arguments")
|
|
264
|
+
invocation = DecoratorInvocation(
|
|
265
|
+
name=name,
|
|
266
|
+
args=tuple(_literal_value(arg, name) for arg in decorator.args),
|
|
267
|
+
kwargs=tuple(
|
|
268
|
+
(keyword.arg, _literal_value(keyword.value, name))
|
|
269
|
+
for keyword in decorator.keywords
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
if name == "grant_dynamodb":
|
|
273
|
+
_validate_grant(invocation, "table_name")
|
|
274
|
+
elif name == "grant_s3":
|
|
275
|
+
_validate_grant(invocation, "bucket_name")
|
|
276
|
+
elif name == "permission":
|
|
277
|
+
invocation = _validate_permission(invocation)
|
|
278
|
+
return invocation
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_file_nodes(parsed_tree, id, directory):
|
|
282
|
+
node_list = []
|
|
283
|
+
for node in parsed_tree.body:
|
|
284
|
+
if isinstance(node, ast.FunctionDef):
|
|
285
|
+
is_lambda_http = False
|
|
286
|
+
decorator_invocations = []
|
|
287
|
+
func_name = node.name
|
|
288
|
+
paths = [] #A handler could have multiple paths with multiple HTTP methods
|
|
289
|
+
for decorator in node.decorator_list:
|
|
290
|
+
invocation = _parse_invocation(decorator)
|
|
291
|
+
if invocation is None:
|
|
292
|
+
continue
|
|
293
|
+
decorator_invocations.append(invocation)
|
|
294
|
+
if invocation.name in Method.ALLOWED_METHODS:
|
|
295
|
+
if len(invocation.args) != 1 or invocation.kwargs:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"{invocation.name} requires one positional path"
|
|
298
|
+
)
|
|
299
|
+
path = invocation.args[0]
|
|
300
|
+
_require_non_empty_string(path, invocation.name, "path")
|
|
301
|
+
is_lambda_http = True
|
|
302
|
+
paths.append((path, invocation.name))
|
|
303
|
+
|
|
304
|
+
#ast.FunctionDef ends. If the Function had an HTTP Decorator it means it's a lambda function
|
|
305
|
+
if is_lambda_http:
|
|
306
|
+
for key,value in paths:
|
|
307
|
+
#If id (file) is at the root of directory, no change needed. Else we need to only get the file
|
|
308
|
+
filepath = id[id.rindex(os.sep)+1:] if id.count(os.sep) > 0 else id
|
|
309
|
+
#Concatenate directory to id (file) for lambda entry point / separate "index" file from path
|
|
310
|
+
full_path = os.path.join(directory,id)
|
|
311
|
+
|
|
312
|
+
method = Method(path_to_file=full_path[:full_path.rindex(os.sep)], file= filepath, handler=func_name, method=value, decorators=decorator_invocations)
|
|
313
|
+
node_exists = False
|
|
314
|
+
if len(node_list) > 0:
|
|
315
|
+
for node in node_list:
|
|
316
|
+
if node.get_path() == key:
|
|
317
|
+
node.add_method(method)
|
|
318
|
+
node_exists = True
|
|
319
|
+
if not node_exists:
|
|
320
|
+
new_resource = Resource(path=key)
|
|
321
|
+
new_resource.add_method(method)
|
|
322
|
+
node_list.append(new_resource)
|
|
323
|
+
|
|
324
|
+
return node_list
|
|
325
|
+
|
|
326
|
+
def dump_tree(node, level=0):
|
|
327
|
+
if node is None:
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# Print current node with indentation
|
|
331
|
+
print(" " * level, node)
|
|
332
|
+
|
|
333
|
+
# Recursively print connections
|
|
334
|
+
for child in node.get_connections():
|
|
335
|
+
dump_tree(child, level + 1)
|
|
336
|
+
|
|
337
|
+
def has_decorators(parsed_tree):
|
|
338
|
+
for node in ast.walk(parsed_tree):
|
|
339
|
+
if isinstance(node, ast.FunctionDef) and node.decorator_list: #Es necesario fijarme si tiene si o si un decorador de tipo http? o con que tenga alcanza
|
|
340
|
+
return True
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_lambda_graph(directory):
|
|
345
|
+
python_files = []
|
|
346
|
+
graph = Resource('/')
|
|
347
|
+
for root, dirs, files in os.walk(directory):
|
|
348
|
+
for file in files:
|
|
349
|
+
if file.endswith(".py"):
|
|
350
|
+
python_files.append(os.path.join(root, file))
|
|
351
|
+
|
|
352
|
+
for file in python_files:
|
|
353
|
+
parsed_tree = parse_file(file)
|
|
354
|
+
file_id = file[len(directory)+len(os.sep):]
|
|
355
|
+
if has_decorators(parsed_tree):
|
|
356
|
+
new_nodes = get_file_nodes(parsed_tree, file_id, directory)
|
|
357
|
+
for node in new_nodes:
|
|
358
|
+
graph.insert_node(node)
|
|
359
|
+
else:
|
|
360
|
+
print("Skipped " + file + ' due to it not having decorators')
|
|
361
|
+
return graph
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from typing import Optional, Union
|
|
2
|
+
|
|
3
|
+
from aws_cdk import aws_apigateway as apigateway
|
|
4
|
+
from aws_cdk import aws_apigatewayv2 as apigatewayv2
|
|
5
|
+
from constructs import Construct
|
|
6
|
+
|
|
7
|
+
from .api_type import ApiType
|
|
8
|
+
from .lambda_api_config import LambdaApiConfig
|
|
9
|
+
from .source_layout import SourceLayout
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LambdaApi(Construct):
|
|
13
|
+
"""Build decorated Lambda handlers behind an API Gateway."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
scope: Construct,
|
|
18
|
+
construct_id: str,
|
|
19
|
+
*,
|
|
20
|
+
lambda_path: str,
|
|
21
|
+
source_layout: SourceLayout = SourceLayout.ROOT,
|
|
22
|
+
layers_path: Optional[str] = None,
|
|
23
|
+
api: Optional[
|
|
24
|
+
Union[apigateway.IRestApi, apigatewayv2.HttpApi]
|
|
25
|
+
] = None,
|
|
26
|
+
api_type: Optional[ApiType] = None,
|
|
27
|
+
config: Optional[LambdaApiConfig] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(scope, construct_id)
|
|
30
|
+
|
|
31
|
+
if not isinstance(source_layout, SourceLayout):
|
|
32
|
+
raise TypeError("source_layout must be a SourceLayout")
|
|
33
|
+
if config is not None and not isinstance(config, LambdaApiConfig):
|
|
34
|
+
raise TypeError("config must be a LambdaApiConfig or None")
|
|
35
|
+
if api_type is not None and not isinstance(api_type, ApiType):
|
|
36
|
+
raise TypeError("api_type must be an ApiType or None")
|
|
37
|
+
|
|
38
|
+
inferred_type = self._infer_api_type(api) if api is not None else None
|
|
39
|
+
if api_type is not None and inferred_type is not None and api_type is not inferred_type:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"api_type {api_type!r} conflicts with supplied {inferred_type.value} API"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
resolved_type = inferred_type or api_type or ApiType.REST
|
|
45
|
+
resolved_config = config if config is not None else LambdaApiConfig()
|
|
46
|
+
|
|
47
|
+
if api is None:
|
|
48
|
+
if resolved_type is ApiType.REST:
|
|
49
|
+
api = apigateway.RestApi(self, "RestApi")
|
|
50
|
+
else:
|
|
51
|
+
api = apigatewayv2.HttpApi(self, "HttpApi")
|
|
52
|
+
|
|
53
|
+
self._api = api
|
|
54
|
+
self._api_type = resolved_type
|
|
55
|
+
resource_builder = resolved_config._create_resource_builder()
|
|
56
|
+
|
|
57
|
+
if resolved_type is ApiType.REST:
|
|
58
|
+
resource_builder.build(
|
|
59
|
+
self, api.root, lambda_path, source_layout=source_layout,
|
|
60
|
+
layers_path=layers_path)
|
|
61
|
+
else:
|
|
62
|
+
resource_builder.build_http(
|
|
63
|
+
self, api, lambda_path, source_layout=source_layout,
|
|
64
|
+
layers_path=layers_path)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _infer_api_type(api: object) -> ApiType:
|
|
68
|
+
# IRestApi is a non-runtime-checkable Protocol in CDK Python. Both
|
|
69
|
+
# created and imported REST APIs derive from its concrete RestApiBase.
|
|
70
|
+
if isinstance(api, apigateway.RestApiBase):
|
|
71
|
+
return ApiType.REST
|
|
72
|
+
if isinstance(api, apigatewayv2.HttpApi):
|
|
73
|
+
return ApiType.HTTP
|
|
74
|
+
raise TypeError(
|
|
75
|
+
"api must be an apigateway IRestApi implementation or "
|
|
76
|
+
"apigatewayv2.HttpApi"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def api(self) -> Union[apigateway.IRestApi, apigatewayv2.HttpApi]:
|
|
81
|
+
return self._api
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def api_type(self) -> ApiType:
|
|
85
|
+
return self._api_type
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from typing import Mapping, Optional, Sequence
|
|
2
|
+
|
|
3
|
+
from aws_cdk import Duration
|
|
4
|
+
from aws_cdk import aws_ec2 as ec2
|
|
5
|
+
from aws_cdk import aws_dynamodb as dynamodb
|
|
6
|
+
from aws_cdk import aws_iam as iam
|
|
7
|
+
from aws_cdk import aws_lambda as lambda_
|
|
8
|
+
from aws_cdk import aws_s3 as s3
|
|
9
|
+
|
|
10
|
+
from .resource_builder import ResourceBuilder
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LambdaApiConfig:
|
|
14
|
+
"""Reusable defaults and named resources for a :class:`LambdaApi` build."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
runtime: Optional[lambda_.Runtime] = None,
|
|
20
|
+
timeout: Optional[Duration] = None,
|
|
21
|
+
memory_size: Optional[int] = None,
|
|
22
|
+
vpc: Optional[ec2.IVpc] = None,
|
|
23
|
+
vpc_subnets: Optional[ec2.SubnetSelection] = None,
|
|
24
|
+
role: Optional[iam.IRole] = None,
|
|
25
|
+
layers: Optional[Sequence[lambda_.ILayerVersion]] = None,
|
|
26
|
+
security_groups: Optional[Sequence[ec2.ISecurityGroup]] = None,
|
|
27
|
+
environment: Optional[Mapping[str, str]] = None,
|
|
28
|
+
dynamodb_tables: Optional[Mapping[str, dynamodb.ITable]] = None,
|
|
29
|
+
s3_buckets: Optional[Mapping[str, s3.IBucket]] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._default_runtime = runtime
|
|
32
|
+
self._default_timeout = timeout
|
|
33
|
+
self._default_memory_size = memory_size
|
|
34
|
+
self._default_vpc = (vpc, vpc_subnets) if vpc is not None else None
|
|
35
|
+
self._default_role = role
|
|
36
|
+
|
|
37
|
+
self._common_layers = list(layers) if layers is not None else []
|
|
38
|
+
self._common_security_groups = (
|
|
39
|
+
list(security_groups) if security_groups is not None else []
|
|
40
|
+
)
|
|
41
|
+
self._common_environments = dict(environment) if environment is not None else {}
|
|
42
|
+
|
|
43
|
+
self._custom_runtimes = {}
|
|
44
|
+
self._custom_roles = {}
|
|
45
|
+
self._custom_layers = {}
|
|
46
|
+
self._custom_environments = {}
|
|
47
|
+
self._custom_security_groups = {}
|
|
48
|
+
self._custom_vpcs = {}
|
|
49
|
+
|
|
50
|
+
self._dynamodb_tables = {}
|
|
51
|
+
self._s3_buckets = {}
|
|
52
|
+
for key, table in (dynamodb_tables or {}).items():
|
|
53
|
+
self.add_dynamodb_table(key, table)
|
|
54
|
+
for key, bucket in (s3_buckets or {}).items():
|
|
55
|
+
self.add_s3_bucket(key, bucket)
|
|
56
|
+
|
|
57
|
+
def set_default_runtime(self, runtime: Optional[lambda_.Runtime]) -> None:
|
|
58
|
+
self._default_runtime = runtime
|
|
59
|
+
|
|
60
|
+
def set_default_timeout(self, timeout: Optional[Duration]) -> None:
|
|
61
|
+
self._default_timeout = timeout
|
|
62
|
+
|
|
63
|
+
def set_default_memory_size(self, memory_size: Optional[int]) -> None:
|
|
64
|
+
self._default_memory_size = memory_size
|
|
65
|
+
|
|
66
|
+
def set_default_vpc(
|
|
67
|
+
self,
|
|
68
|
+
vpc: Optional[ec2.IVpc],
|
|
69
|
+
vpc_subnets: Optional[ec2.SubnetSelection] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._default_vpc = (vpc, vpc_subnets) if vpc is not None else None
|
|
72
|
+
|
|
73
|
+
def set_default_role(self, role: Optional[iam.IRole]) -> None:
|
|
74
|
+
self._default_role = role
|
|
75
|
+
|
|
76
|
+
def add_common_layer(self, layer: lambda_.ILayerVersion) -> None:
|
|
77
|
+
if layer not in self._common_layers:
|
|
78
|
+
self._common_layers.append(layer)
|
|
79
|
+
|
|
80
|
+
def add_common_security_group(
|
|
81
|
+
self, security_group: ec2.ISecurityGroup
|
|
82
|
+
) -> None:
|
|
83
|
+
if security_group not in self._common_security_groups:
|
|
84
|
+
self._common_security_groups.append(security_group)
|
|
85
|
+
|
|
86
|
+
def add_common_environment(self, key: str, value: str) -> None:
|
|
87
|
+
self._common_environments[key] = value
|
|
88
|
+
|
|
89
|
+
def add_custom_runtime(self, key: str, runtime: lambda_.Runtime) -> None:
|
|
90
|
+
self._custom_runtimes[key] = runtime
|
|
91
|
+
|
|
92
|
+
def add_custom_role(self, key: str, role: iam.IRole) -> None:
|
|
93
|
+
self._custom_roles[key] = role
|
|
94
|
+
|
|
95
|
+
def add_custom_layer(self, key: str, layer: lambda_.ILayerVersion) -> None:
|
|
96
|
+
self._custom_layers[key] = layer
|
|
97
|
+
|
|
98
|
+
def add_custom_environment(self, key: str, value: str) -> None:
|
|
99
|
+
self._custom_environments[key] = value
|
|
100
|
+
|
|
101
|
+
def add_custom_security_group(
|
|
102
|
+
self, key: str, security_group: ec2.ISecurityGroup
|
|
103
|
+
) -> None:
|
|
104
|
+
self._custom_security_groups[key] = security_group
|
|
105
|
+
|
|
106
|
+
def add_custom_vpc(
|
|
107
|
+
self,
|
|
108
|
+
key: str,
|
|
109
|
+
vpc: ec2.IVpc,
|
|
110
|
+
vpc_subnets: Optional[ec2.SubnetSelection] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._custom_vpcs[key] = (vpc, vpc_subnets)
|
|
113
|
+
|
|
114
|
+
def add_dynamodb_table(self, key: str, table: dynamodb.ITable) -> None:
|
|
115
|
+
self._add_resource(key, table, self._dynamodb_tables, "DynamoDB table")
|
|
116
|
+
|
|
117
|
+
def add_s3_bucket(self, key: str, bucket: s3.IBucket) -> None:
|
|
118
|
+
self._add_resource(key, bucket, self._s3_buckets, "S3 bucket")
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _add_resource(key: str, resource, registry: dict, resource_type: str) -> None:
|
|
122
|
+
if not isinstance(key, str):
|
|
123
|
+
raise TypeError("Resource registry keys must be strings")
|
|
124
|
+
if not key.strip():
|
|
125
|
+
raise ValueError("Resource registry keys must not be empty or whitespace")
|
|
126
|
+
if resource is None or isinstance(resource, (str, int)):
|
|
127
|
+
raise TypeError(f"{resource_type} must be a CDK resource object")
|
|
128
|
+
if key in registry:
|
|
129
|
+
raise ValueError(f"Resource key {key!r} is already registered")
|
|
130
|
+
registry[key] = resource
|
|
131
|
+
|
|
132
|
+
def _create_resource_builder(self) -> ResourceBuilder:
|
|
133
|
+
"""Create an isolated builder snapshot without copying CDK resources."""
|
|
134
|
+
return ResourceBuilder(
|
|
135
|
+
default_runtime=self._default_runtime,
|
|
136
|
+
default_timeout=self._default_timeout,
|
|
137
|
+
default_memory_size=self._default_memory_size,
|
|
138
|
+
default_vpc=self._default_vpc,
|
|
139
|
+
default_role=self._default_role,
|
|
140
|
+
common_layers=list(self._common_layers),
|
|
141
|
+
common_security_groups=list(self._common_security_groups),
|
|
142
|
+
common_environments=dict(self._common_environments),
|
|
143
|
+
custom_runtimes=dict(self._custom_runtimes),
|
|
144
|
+
custom_roles=dict(self._custom_roles),
|
|
145
|
+
custom_layers=dict(self._custom_layers),
|
|
146
|
+
custom_environments=dict(self._custom_environments),
|
|
147
|
+
custom_security_groups=dict(self._custom_security_groups),
|
|
148
|
+
custom_vpcs=dict(self._custom_vpcs),
|
|
149
|
+
dynamodb_tables=dict(self._dynamodb_tables),
|
|
150
|
+
s3_buckets=dict(self._s3_buckets),
|
|
151
|
+
)
|