modmex-lambda 0.1.0__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.
- modmex_lambda/__init__.py +62 -0
- modmex_lambda/data_classes/__init__.py +49 -0
- modmex_lambda/data_classes/api_gateway_authorizer_event.py +38 -0
- modmex_lambda/data_classes/api_gateway_proxy_event.py +328 -0
- modmex_lambda/data_classes/api_gateway_websocket_event.py +40 -0
- modmex_lambda/data_classes/cognito_user_pool_event.py +599 -0
- modmex_lambda/data_classes/common.py +441 -0
- modmex_lambda/event_handler/__init__.py +45 -0
- modmex_lambda/event_handler/api_gateway.py +331 -0
- modmex_lambda/event_handler/constants.py +3 -0
- modmex_lambda/event_handler/content_types.py +13 -0
- modmex_lambda/event_handler/cors.py +97 -0
- modmex_lambda/event_handler/dependencies/__init__.py +0 -0
- modmex_lambda/event_handler/dependencies/compat.py +231 -0
- modmex_lambda/event_handler/dependencies/dependant.py +279 -0
- modmex_lambda/event_handler/dependencies/dependency_middleware.py +423 -0
- modmex_lambda/event_handler/dependencies/depends.py +184 -0
- modmex_lambda/event_handler/dependencies/params.py +317 -0
- modmex_lambda/event_handler/dependencies/types.py +14 -0
- modmex_lambda/event_handler/exception_handler.py +70 -0
- modmex_lambda/event_handler/exceptions.py +72 -0
- modmex_lambda/event_handler/gateway_response.py +96 -0
- modmex_lambda/event_handler/middlewares.py +33 -0
- modmex_lambda/event_handler/params.py +44 -0
- modmex_lambda/event_handler/request.py +70 -0
- modmex_lambda/event_handler/response.py +60 -0
- modmex_lambda/event_handler/routing.py +507 -0
- modmex_lambda/event_handler/routing_fallbacks.py +92 -0
- modmex_lambda/event_handler/types.py +31 -0
- modmex_lambda/event_sources.py +53 -0
- modmex_lambda/exceptions.py +3 -0
- modmex_lambda/logging.py +99 -0
- modmex_lambda/params.py +3 -0
- modmex_lambda/parser.py +47 -0
- modmex_lambda/request.py +3 -0
- modmex_lambda/resolver.py +3 -0
- modmex_lambda/response.py +3 -0
- modmex_lambda/routing.py +3 -0
- modmex_lambda/shared/__init__.py +0 -0
- modmex_lambda/shared/cookies.py +84 -0
- modmex_lambda/shared/headers_serializer.py +65 -0
- modmex_lambda/shared/json_encoder.py +53 -0
- modmex_lambda/shared/types.py +4 -0
- modmex_lambda/validation.py +178 -0
- modmex_lambda-0.1.0.dist-info/METADATA +375 -0
- modmex_lambda-0.1.0.dist-info/RECORD +48 -0
- modmex_lambda-0.1.0.dist-info/WHEEL +4 -0
- modmex_lambda-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Routing primitives for API Gateway resolvers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any, Callable, Pattern
|
|
9
|
+
|
|
10
|
+
from modmex_lambda.event_handler.constants import DEFAULT_STATUS_CODE
|
|
11
|
+
from modmex_lambda.event_handler.dependencies.dependant import get_dependant, is_request_annotation
|
|
12
|
+
from modmex_lambda.event_handler.dependencies.depends import solve_dependencies
|
|
13
|
+
from modmex_lambda.event_handler.dependencies.params import Dependant
|
|
14
|
+
from modmex_lambda.event_handler.response import Response
|
|
15
|
+
from modmex_lambda.event_handler.types import IApiGatewayResolver
|
|
16
|
+
from modmex_lambda.shared.types import AnyCallableT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Handler = AnyCallableT
|
|
20
|
+
NextMiddleware = Callable[..., dict | tuple | Response]
|
|
21
|
+
|
|
22
|
+
_DYNAMIC_ROUTE_PATTERN = r"(<\w+>)"
|
|
23
|
+
_SAFE_URI = "-._~()'!*:@,;=+&$" # https://www.ietf.org/rfc/rfc3986.txt
|
|
24
|
+
_UNSAFE_URI = r"%<> \[\]{}|^"
|
|
25
|
+
_NAMED_GROUP_BOUNDARY_PATTERN = rf"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"
|
|
26
|
+
_ROUTE_REGEX = "^{}$"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IRoute(ABC):
|
|
30
|
+
method: str
|
|
31
|
+
path: str
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def invoke(
|
|
35
|
+
self,
|
|
36
|
+
router_middlewares: list[Callable],
|
|
37
|
+
app: IApiGatewayResolver,
|
|
38
|
+
route_arguments: dict[str, str],
|
|
39
|
+
) -> Response:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def match(self, path: str) -> dict[str, str] | None:
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def dependant(self) -> Dependant:
|
|
49
|
+
raise NotImplementedError()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HasRoutes(ABC):
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def route(
|
|
55
|
+
self,
|
|
56
|
+
rule: str,
|
|
57
|
+
method: str | list[str] | tuple[str],
|
|
58
|
+
description: str | None = None,
|
|
59
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
60
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
61
|
+
cors: bool | None = None,
|
|
62
|
+
compress: bool = False,
|
|
63
|
+
cache_control: str | None = None,
|
|
64
|
+
):
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def get(
|
|
68
|
+
self,
|
|
69
|
+
rule: str,
|
|
70
|
+
description: str | None = None,
|
|
71
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
72
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
73
|
+
cors: bool | None = None,
|
|
74
|
+
compress: bool = False,
|
|
75
|
+
cache_control: str | None = None,
|
|
76
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
77
|
+
return self.route(
|
|
78
|
+
rule=rule,
|
|
79
|
+
method="GET",
|
|
80
|
+
description=description,
|
|
81
|
+
status_code=status_code,
|
|
82
|
+
middlewares=middlewares,
|
|
83
|
+
cors=cors,
|
|
84
|
+
compress=compress,
|
|
85
|
+
cache_control=cache_control,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def post(
|
|
89
|
+
self,
|
|
90
|
+
rule: str,
|
|
91
|
+
description: str | None = None,
|
|
92
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
93
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
94
|
+
cors: bool | None = None,
|
|
95
|
+
compress: bool = False,
|
|
96
|
+
cache_control: str | None = None,
|
|
97
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
98
|
+
return self.route(
|
|
99
|
+
rule=rule,
|
|
100
|
+
method="POST",
|
|
101
|
+
description=description,
|
|
102
|
+
status_code=status_code,
|
|
103
|
+
middlewares=middlewares,
|
|
104
|
+
cors=cors,
|
|
105
|
+
compress=compress,
|
|
106
|
+
cache_control=cache_control,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def put(
|
|
110
|
+
self,
|
|
111
|
+
rule: str,
|
|
112
|
+
description: str | None = None,
|
|
113
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
114
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
115
|
+
cors: bool | None = None,
|
|
116
|
+
compress: bool = False,
|
|
117
|
+
cache_control: str | None = None,
|
|
118
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
119
|
+
return self.route(
|
|
120
|
+
rule=rule,
|
|
121
|
+
method="PUT",
|
|
122
|
+
description=description,
|
|
123
|
+
status_code=status_code,
|
|
124
|
+
middlewares=middlewares,
|
|
125
|
+
cors=cors,
|
|
126
|
+
compress=compress,
|
|
127
|
+
cache_control=cache_control,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def patch(
|
|
131
|
+
self,
|
|
132
|
+
rule: str,
|
|
133
|
+
description: str | None = None,
|
|
134
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
135
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
136
|
+
cors: bool | None = None,
|
|
137
|
+
compress: bool = False,
|
|
138
|
+
cache_control: str | None = None,
|
|
139
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
140
|
+
return self.route(
|
|
141
|
+
rule=rule,
|
|
142
|
+
method="PATCH",
|
|
143
|
+
description=description,
|
|
144
|
+
status_code=status_code,
|
|
145
|
+
middlewares=middlewares,
|
|
146
|
+
cors=cors,
|
|
147
|
+
compress=compress,
|
|
148
|
+
cache_control=cache_control,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def delete(
|
|
152
|
+
self,
|
|
153
|
+
rule: str,
|
|
154
|
+
description: str | None = None,
|
|
155
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
156
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
157
|
+
cors: bool | None = None,
|
|
158
|
+
compress: bool = False,
|
|
159
|
+
cache_control: str | None = None,
|
|
160
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
161
|
+
return self.route(
|
|
162
|
+
rule=rule,
|
|
163
|
+
method="DELETE",
|
|
164
|
+
description=description,
|
|
165
|
+
status_code=status_code,
|
|
166
|
+
middlewares=middlewares,
|
|
167
|
+
cors=cors,
|
|
168
|
+
compress=compress,
|
|
169
|
+
cache_control=cache_control,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def header(
|
|
173
|
+
self,
|
|
174
|
+
rule: str,
|
|
175
|
+
description: str | None = None,
|
|
176
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
177
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
178
|
+
cors: bool | None = None,
|
|
179
|
+
compress: bool = False,
|
|
180
|
+
cache_control: str | None = None,
|
|
181
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
182
|
+
return self.route(
|
|
183
|
+
rule=rule,
|
|
184
|
+
method="HEAD",
|
|
185
|
+
description=description,
|
|
186
|
+
status_code=status_code,
|
|
187
|
+
middlewares=middlewares,
|
|
188
|
+
cors=cors,
|
|
189
|
+
compress=compress,
|
|
190
|
+
cache_control=cache_control,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class IRouter(ABC):
|
|
195
|
+
_routes: list[IRoute]
|
|
196
|
+
|
|
197
|
+
@abstractmethod
|
|
198
|
+
def include_router(self, router: IRouter, prefix: str = "") -> None:
|
|
199
|
+
raise NotImplementedError
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class MiddlewareChainLink:
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
current_middleware: Callable[..., Any],
|
|
206
|
+
next_middleware: Callable[..., Any],
|
|
207
|
+
) -> None:
|
|
208
|
+
self.current_middleware = current_middleware
|
|
209
|
+
self.next_middleware = next_middleware
|
|
210
|
+
self._next_middleware_name = callable_name(next_middleware)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def __name__(self) -> str:
|
|
214
|
+
return callable_name(self.current_middleware)
|
|
215
|
+
|
|
216
|
+
def __str__(self) -> str:
|
|
217
|
+
return f"{self.__name__} -> {self._next_middleware_name}"
|
|
218
|
+
|
|
219
|
+
def __call__(self, app: IApiGatewayResolver) -> dict | tuple | Response:
|
|
220
|
+
return self.current_middleware(app, self.next_middleware)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def callable_name(value: Callable[..., Any]) -> str:
|
|
224
|
+
return getattr(value, "__name__", value.__class__.__name__)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Route(IRoute):
|
|
228
|
+
__slots__ = (
|
|
229
|
+
"method",
|
|
230
|
+
"path",
|
|
231
|
+
"handler",
|
|
232
|
+
"pattern",
|
|
233
|
+
"description",
|
|
234
|
+
"status_code",
|
|
235
|
+
"middlewares",
|
|
236
|
+
"_middleware_stack",
|
|
237
|
+
"_middleware_stack_built",
|
|
238
|
+
"cors",
|
|
239
|
+
"cache_control",
|
|
240
|
+
"compress",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
method: str,
|
|
246
|
+
path: str,
|
|
247
|
+
handler: Callable,
|
|
248
|
+
cors: bool,
|
|
249
|
+
pattern: Pattern,
|
|
250
|
+
description: str | None = None,
|
|
251
|
+
status_code: int | None = DEFAULT_STATUS_CODE,
|
|
252
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
253
|
+
compress: bool = False,
|
|
254
|
+
cache_control: str | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
self.method = method
|
|
257
|
+
self.path = path
|
|
258
|
+
self.handler = handler
|
|
259
|
+
self.cors = cors if cors is not None else False
|
|
260
|
+
self._middleware_stack = handler
|
|
261
|
+
self.pattern = pattern
|
|
262
|
+
self.middlewares = middlewares or []
|
|
263
|
+
self._middleware_stack_built = False
|
|
264
|
+
self.description = description
|
|
265
|
+
self.status_code = status_code
|
|
266
|
+
self._dependant: Dependant | None = None
|
|
267
|
+
self.responses: dict[int, Any] | None = None
|
|
268
|
+
self.compress = compress
|
|
269
|
+
self.cache_control = cache_control
|
|
270
|
+
self.request_param_name: str | None = None
|
|
271
|
+
self.request_param_name_checked = False
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def dependant(self) -> Dependant:
|
|
275
|
+
if not self._dependant:
|
|
276
|
+
self._dependant = get_dependant(path=self.path, call=self.handler, responses=self.responses)
|
|
277
|
+
return self._dependant
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def has_dependencies(self) -> bool:
|
|
281
|
+
return bool(self.dependant.dependencies)
|
|
282
|
+
|
|
283
|
+
def invoke(
|
|
284
|
+
self,
|
|
285
|
+
router_middlewares: list[Callable],
|
|
286
|
+
app: IApiGatewayResolver,
|
|
287
|
+
route_arguments: dict[str, str],
|
|
288
|
+
) -> Response:
|
|
289
|
+
if not self._middleware_stack_built:
|
|
290
|
+
self._build_middleware_stack(router_middlewares=router_middlewares, app=app)
|
|
291
|
+
|
|
292
|
+
app.append_context(_route_args=route_arguments)
|
|
293
|
+
return self._middleware_stack(app)
|
|
294
|
+
|
|
295
|
+
def match(self, path: str) -> dict[str, str] | None:
|
|
296
|
+
match = self.pattern.match(path)
|
|
297
|
+
if match is None:
|
|
298
|
+
return None
|
|
299
|
+
if isinstance(match, dict):
|
|
300
|
+
return match
|
|
301
|
+
return match.groupdict()
|
|
302
|
+
|
|
303
|
+
def _build_middleware_stack(self, router_middlewares: list[Callable[..., Any]], app: IApiGatewayResolver) -> None:
|
|
304
|
+
middlewares = [
|
|
305
|
+
app._dependency_middleware,
|
|
306
|
+
*router_middlewares,
|
|
307
|
+
*self.middlewares,
|
|
308
|
+
RouteEndpointInvoker(),
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
for handler in reversed(middlewares):
|
|
312
|
+
self._middleware_stack = MiddlewareChainLink(
|
|
313
|
+
current_middleware=handler,
|
|
314
|
+
next_middleware=self._middleware_stack,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self._middleware_stack_built = True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Router(IRouter):
|
|
321
|
+
__slots__ = (
|
|
322
|
+
"_routes",
|
|
323
|
+
"_routes_with_middlewares",
|
|
324
|
+
"context",
|
|
325
|
+
"_dynamic_routes",
|
|
326
|
+
"_static_routes",
|
|
327
|
+
"_path_methods",
|
|
328
|
+
"cors_methods",
|
|
329
|
+
"exception_handler_manager",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def __init__(self):
|
|
333
|
+
from modmex_lambda.event_handler.exception_handler import ExceptionHandlerManager
|
|
334
|
+
|
|
335
|
+
self._routes: list[Route] = []
|
|
336
|
+
self._routes_with_middlewares: dict[tuple, list[Callable]] = {}
|
|
337
|
+
self.context = {}
|
|
338
|
+
self._dynamic_routes: list[Route] = []
|
|
339
|
+
self._static_routes: dict[tuple[str, str], Route] = {}
|
|
340
|
+
self._path_methods: dict[str, set[str]] = {}
|
|
341
|
+
self.cors_methods: set[str] = set()
|
|
342
|
+
self.exception_handler_manager = ExceptionHandlerManager()
|
|
343
|
+
|
|
344
|
+
def route(
|
|
345
|
+
self,
|
|
346
|
+
rule: str,
|
|
347
|
+
method: str | list[str] | tuple[str],
|
|
348
|
+
description: str | None = None,
|
|
349
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
350
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
351
|
+
cors: bool | None = None,
|
|
352
|
+
compress: bool = False,
|
|
353
|
+
cache_control: str | None = None,
|
|
354
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
355
|
+
def decorator(func: AnyCallableT) -> AnyCallableT:
|
|
356
|
+
capture_definition_locals(func)
|
|
357
|
+
methods = (method,) if isinstance(method, str) else method
|
|
358
|
+
for item in methods:
|
|
359
|
+
self._add_route(
|
|
360
|
+
rule=rule,
|
|
361
|
+
method=item,
|
|
362
|
+
handler=func,
|
|
363
|
+
description=description,
|
|
364
|
+
status_code=status_code,
|
|
365
|
+
middlewares=middlewares,
|
|
366
|
+
cors=cors,
|
|
367
|
+
compress=compress,
|
|
368
|
+
cache_control=cache_control,
|
|
369
|
+
)
|
|
370
|
+
return func
|
|
371
|
+
|
|
372
|
+
return decorator
|
|
373
|
+
|
|
374
|
+
def _add_route(
|
|
375
|
+
self,
|
|
376
|
+
rule: str,
|
|
377
|
+
method: str,
|
|
378
|
+
handler: AnyCallableT,
|
|
379
|
+
description: str | None = None,
|
|
380
|
+
status_code: int | None = DEFAULT_STATUS_CODE,
|
|
381
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
382
|
+
cors: bool | None = None,
|
|
383
|
+
compress: bool = False,
|
|
384
|
+
cache_control: str | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
method = method.upper()
|
|
387
|
+
route = Route(
|
|
388
|
+
method=method,
|
|
389
|
+
path=rule,
|
|
390
|
+
handler=handler,
|
|
391
|
+
pattern=self.compile_regex(rule),
|
|
392
|
+
description=description,
|
|
393
|
+
status_code=status_code,
|
|
394
|
+
middlewares=middlewares,
|
|
395
|
+
cors=cors,
|
|
396
|
+
compress=compress,
|
|
397
|
+
cache_control=cache_control,
|
|
398
|
+
)
|
|
399
|
+
self._routes.append(route)
|
|
400
|
+
self._path_methods.setdefault(rule, set()).add(method)
|
|
401
|
+
|
|
402
|
+
if route.pattern.groups > 0:
|
|
403
|
+
self._dynamic_routes.append(route)
|
|
404
|
+
else:
|
|
405
|
+
self._static_routes[(method, rule)] = route
|
|
406
|
+
|
|
407
|
+
self.cors_methods.add(method)
|
|
408
|
+
|
|
409
|
+
def include_router(self, router: Router, prefix: str = "") -> None:
|
|
410
|
+
for route in router._routes:
|
|
411
|
+
self._add_route(
|
|
412
|
+
rule=prefix + route.path,
|
|
413
|
+
method=route.method,
|
|
414
|
+
handler=route.handler,
|
|
415
|
+
description=route.description,
|
|
416
|
+
status_code=route.status_code,
|
|
417
|
+
middlewares=route.middlewares,
|
|
418
|
+
cors=route.cors,
|
|
419
|
+
compress=route.compress,
|
|
420
|
+
cache_control=route.cache_control,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]):
|
|
424
|
+
return self.exception_handler_manager.exception_handler(exc_class)
|
|
425
|
+
|
|
426
|
+
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], set[str]]:
|
|
427
|
+
method = method.upper()
|
|
428
|
+
route = self._static_routes.get((method, path)) or self._static_routes.get(("ANY", path))
|
|
429
|
+
if route is not None:
|
|
430
|
+
return route, {}, set()
|
|
431
|
+
|
|
432
|
+
allowed_methods = set(self._path_methods.get(path, set()))
|
|
433
|
+
|
|
434
|
+
for route in self._dynamic_routes:
|
|
435
|
+
path_params = route.match(path)
|
|
436
|
+
if path_params is None:
|
|
437
|
+
continue
|
|
438
|
+
allowed_methods.add(route.method.upper())
|
|
439
|
+
if route.method.upper() in {method, "ANY"}:
|
|
440
|
+
return route, path_params, set()
|
|
441
|
+
return None, {}, allowed_methods
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def compile_regex(rule: str, base_regex: str = _ROUTE_REGEX):
|
|
445
|
+
rule_regex = re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, rule)
|
|
446
|
+
return re.compile(base_regex.format(rule_regex))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def capture_definition_locals(func: Callable[..., Any]) -> None:
|
|
450
|
+
frame = inspect.currentframe()
|
|
451
|
+
if frame is not None and frame.f_back is not None and frame.f_back.f_back is not None:
|
|
452
|
+
setattr(func, "__modmex_lambda_localns__", dict(frame.f_back.f_back.f_locals))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _find_request_param_name(func: Callable) -> str | None:
|
|
456
|
+
from typing import get_type_hints
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
hints = get_type_hints(func)
|
|
460
|
+
except Exception:
|
|
461
|
+
hints = {}
|
|
462
|
+
|
|
463
|
+
for param_name, annotation in hints.items():
|
|
464
|
+
if is_request_annotation(annotation):
|
|
465
|
+
return param_name
|
|
466
|
+
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class RouteEndpointInvoker:
|
|
471
|
+
def __call__(self, app: IApiGatewayResolver, next_middleware: NextMiddleware) -> Response:
|
|
472
|
+
return self.handler(app, next_middleware)
|
|
473
|
+
|
|
474
|
+
def handler(self, app: IApiGatewayResolver, next_middleware: NextMiddleware) -> Response:
|
|
475
|
+
route_args: dict = app.context.get("_route_args", {})
|
|
476
|
+
route: Route | None = app.context.get("_route")
|
|
477
|
+
|
|
478
|
+
if route is not None:
|
|
479
|
+
if not route.request_param_name_checked:
|
|
480
|
+
route.request_param_name = _find_request_param_name(next_middleware)
|
|
481
|
+
route.request_param_name_checked = True
|
|
482
|
+
|
|
483
|
+
if route.request_param_name:
|
|
484
|
+
route_args = {
|
|
485
|
+
**route_args,
|
|
486
|
+
route.request_param_name: app.request,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if route.has_dependencies:
|
|
490
|
+
route_args.update(
|
|
491
|
+
solve_dependencies(
|
|
492
|
+
dependant=route.dependant,
|
|
493
|
+
request=app.request,
|
|
494
|
+
dependency_overrides=app.dependency_overrides or None,
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return app._to_response(next_middleware(**route_args))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
__all__ = [
|
|
502
|
+
"HasRoutes",
|
|
503
|
+
"IRoute",
|
|
504
|
+
"IRouter",
|
|
505
|
+
"Route",
|
|
506
|
+
"Router",
|
|
507
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Synthetic fallback routes for API Gateway routing misses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import TYPE_CHECKING, Callable
|
|
7
|
+
|
|
8
|
+
from modmex_lambda.event_handler import content_types
|
|
9
|
+
from modmex_lambda.event_handler.cors import CORSConfig
|
|
10
|
+
from modmex_lambda.event_handler.exceptions import MethodNotAllowedError, NotFoundError
|
|
11
|
+
from modmex_lambda.event_handler.response import Response
|
|
12
|
+
from modmex_lambda.event_handler.routing import Route
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from modmex_lambda.event_handler.api_gateway import ApiGatewayResolver
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RoutingFallbackHandler:
|
|
19
|
+
def __init__(self, app: ApiGatewayResolver) -> None:
|
|
20
|
+
self.app = app
|
|
21
|
+
|
|
22
|
+
def not_found(self, method: str, path: str) -> Response:
|
|
23
|
+
def not_found_handler() -> Response:
|
|
24
|
+
if self.app._cors_enabled and method == "OPTIONS":
|
|
25
|
+
return self._preflight_response(self.app._router.cors_methods)
|
|
26
|
+
|
|
27
|
+
custom_response = self._custom_response(NotFoundError)
|
|
28
|
+
if custom_response is not None:
|
|
29
|
+
return custom_response
|
|
30
|
+
|
|
31
|
+
return self._not_found_response()
|
|
32
|
+
|
|
33
|
+
return self._call_synthetic_route(method=method, path=path, handler=not_found_handler)
|
|
34
|
+
|
|
35
|
+
def method_not_allowed(self, method: str, path: str, allowed_methods: set[str]) -> Response:
|
|
36
|
+
def method_not_allowed_handler() -> Response:
|
|
37
|
+
if self.app._cors_enabled and method == "OPTIONS":
|
|
38
|
+
return self._preflight_response(allowed_methods | {"OPTIONS"})
|
|
39
|
+
|
|
40
|
+
custom_response = self._custom_response(MethodNotAllowedError)
|
|
41
|
+
if custom_response is not None:
|
|
42
|
+
return custom_response
|
|
43
|
+
|
|
44
|
+
return self._method_not_allowed_response(allowed_methods)
|
|
45
|
+
|
|
46
|
+
return self._call_synthetic_route(method=method, path=path, handler=method_not_allowed_handler)
|
|
47
|
+
|
|
48
|
+
def _call_synthetic_route(self, method: str, path: str, handler: Callable[[], Response]) -> Response:
|
|
49
|
+
route = Route(
|
|
50
|
+
method=method,
|
|
51
|
+
path=path,
|
|
52
|
+
pattern=self.app._router.compile_regex(r"^.*$"),
|
|
53
|
+
handler=handler,
|
|
54
|
+
cors=self.app._cors_enabled,
|
|
55
|
+
)
|
|
56
|
+
self.app.append_context(_route=route, _path=path)
|
|
57
|
+
return self.app._call_route(route, route_arguments={})
|
|
58
|
+
|
|
59
|
+
def _preflight_response(self, allowed_methods: set[str]) -> Response:
|
|
60
|
+
return Response(
|
|
61
|
+
status_code=HTTPStatus.NO_CONTENT.value,
|
|
62
|
+
content_type=None,
|
|
63
|
+
headers={"Access-Control-Allow-Methods": CORSConfig.build_allow_methods(allowed_methods)},
|
|
64
|
+
body="",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _custom_response(self, exc_class: type[Exception]) -> Response | None:
|
|
68
|
+
handler = self.app._router.exception_handler_manager.lookup_exception_handler(exc_class)
|
|
69
|
+
if handler is None:
|
|
70
|
+
return None
|
|
71
|
+
return handler(exc_class())
|
|
72
|
+
|
|
73
|
+
def _not_found_response(self) -> Response:
|
|
74
|
+
return Response(
|
|
75
|
+
status_code=HTTPStatus.NOT_FOUND.value,
|
|
76
|
+
content_type=content_types.APPLICATION_JSON,
|
|
77
|
+
body={"statusCode": HTTPStatus.NOT_FOUND.value, "message": "Not Found"},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _method_not_allowed_response(self, allowed_methods: set[str]) -> Response:
|
|
81
|
+
return Response(
|
|
82
|
+
status_code=HTTPStatus.METHOD_NOT_ALLOWED.value,
|
|
83
|
+
content_type=content_types.APPLICATION_JSON,
|
|
84
|
+
headers={"Allow": CORSConfig.build_allow_methods(allowed_methods)},
|
|
85
|
+
body={
|
|
86
|
+
"statusCode": HTTPStatus.METHOD_NOT_ALLOWED.value,
|
|
87
|
+
"message": "Method Not Allowed",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["RoutingFallbackHandler"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, TypeVar, Union
|
|
4
|
+
|
|
5
|
+
from abc import ABC
|
|
6
|
+
from modmex_lambda.data_classes.api_gateway_proxy_event import APIGatewayProxyEvent, APIGatewayProxyEventV2
|
|
7
|
+
from modmex_lambda.event_handler.request import Request
|
|
8
|
+
from modmex_lambda.event_handler.response import Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IApiGatewayResolver(ABC):
|
|
12
|
+
context: dict[str, Any]
|
|
13
|
+
current_event: Union[APIGatewayProxyEvent, APIGatewayProxyEventV2]
|
|
14
|
+
dependency_overrides: dict[Callable[..., Any], Callable[..., Any]]
|
|
15
|
+
_dependency_middleware: Callable[..., Any]
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def request(self) -> Request:
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def resolve(self, event: dict[str, Any], context: object) -> dict[str, Any]:
|
|
22
|
+
raise NotImplementedError()
|
|
23
|
+
|
|
24
|
+
def append_context(self, **kwargs: Any) -> None:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def _to_response(self, result: dict | tuple | Response) -> Response:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
EventHandlerInstance = TypeVar("EventHandlerInstance", bound=IApiGatewayResolver)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""event source decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from modmex_lambda.parser import parse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .validation import Validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def event_source(
|
|
15
|
+
*,
|
|
16
|
+
data_class: type,
|
|
17
|
+
model: Any | None = None,
|
|
18
|
+
source: str | None = None,
|
|
19
|
+
validator: "Validator | None" = None,
|
|
20
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
21
|
+
"""Wrap raw Lambda event with a data class and optionally parse nested payloads."""
|
|
22
|
+
|
|
23
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
24
|
+
@wraps(func)
|
|
25
|
+
def wrapper(event: dict[str, Any], context: object, *args: Any, **kwargs: Any) -> Any:
|
|
26
|
+
wrapped_event = data_class(event)
|
|
27
|
+
if model is not None and source:
|
|
28
|
+
_parse_nested_payloads(wrapped_event, model=model, source=source, validator=validator)
|
|
29
|
+
return func(wrapped_event, context, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_nested_payloads(wrapped_event: Any, *, model: Any, source: str, validator: "Validator | None") -> None:
|
|
37
|
+
parsed_attr = f"parsed_{source}"
|
|
38
|
+
|
|
39
|
+
records = getattr(wrapped_event, "records", None)
|
|
40
|
+
if records is not None:
|
|
41
|
+
for record in records:
|
|
42
|
+
value = getattr(record, source)
|
|
43
|
+
parsed = parse(event=value, model=model, validator=validator)
|
|
44
|
+
setattr(record, parsed_attr, parsed)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if hasattr(wrapped_event, source):
|
|
48
|
+
value = getattr(wrapped_event, source)
|
|
49
|
+
parsed = parse(event=value, model=model, validator=validator)
|
|
50
|
+
setattr(wrapped_event, parsed_attr, parsed)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["event_source"]
|