modmex-lambda 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → modmex_lambda-0.3.0}/PKG-INFO +170 -18
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/README.md +169 -17
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/routing.py +43 -1
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/pyproject.toml +1 -1
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_api_gateway.py +20 -1
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_routing.py +21 -1
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.github/workflows/ci.yml +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.github/workflows/release.yml +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.gitignore +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/LICENSE +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_authorizer_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_proxy_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_websocket_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/cognito_user_pool_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/common.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/api_gateway.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/constants.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/content_types.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/cors.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/compat.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/dependant.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/dependency_middleware.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/depends.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/params.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/types.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/exception_handler.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/exceptions.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/gateway_response.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/middlewares.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/params.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/request.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/response.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/routing_fallbacks.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/types.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_sources.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/exceptions.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/logging.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/params.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/parser.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/request.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/resolver.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/response.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/routing.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/cookies.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/headers_serializer.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/json_encoder.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/types.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/validation.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/poetry.lock +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/conftest.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_api_gateway_proxy_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_cognito_user_pool_event.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_common.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/__init__.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_cors.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_dependencies.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_exception_handler.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_gateway_response.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_request.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_response.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/shared/test_cookies_headers.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/shared/test_json_encoder.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_logging.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_parser_event_sources.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_reexports.py +0 -0
- {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modmex-lambda
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Ultra-lightweight AWS Lambda utilities for API Gateway routing and event handling.
|
|
5
5
|
Author: Modmex
|
|
6
6
|
License: MIT
|
|
@@ -60,7 +60,9 @@ def ping():
|
|
|
60
60
|
return {"message": "pong"}
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
handler
|
|
63
|
+
def handler(event, context):
|
|
64
|
+
return app.resolve(event, context)
|
|
65
|
+
|
|
64
66
|
```
|
|
65
67
|
|
|
66
68
|
The internal base resolver is intentionally not exported from the package root;
|
|
@@ -84,6 +86,25 @@ def create_user():
|
|
|
84
86
|
Supported route decorators include `get`, `post`, `put`, `patch`, `delete`,
|
|
85
87
|
`options`, and `any`.
|
|
86
88
|
|
|
89
|
+
You can also declare routes on a standalone router and include it in the
|
|
90
|
+
resolver:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from modmex_lambda import ApiGatewayHttpResolver
|
|
94
|
+
from modmex_lambda.routing import Router
|
|
95
|
+
|
|
96
|
+
app = ApiGatewayHttpResolver()
|
|
97
|
+
router = Router()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.get("/health")
|
|
101
|
+
def health():
|
|
102
|
+
return {"ok": True}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
app.include_router(router)
|
|
106
|
+
```
|
|
107
|
+
|
|
87
108
|
Routers can also strip deployment prefixes:
|
|
88
109
|
|
|
89
110
|
```python
|
|
@@ -161,6 +182,38 @@ Route return values are converted to API Gateway proxy responses:
|
|
|
161
182
|
- `(body, status_code)` sets the response status.
|
|
162
183
|
- `Response` gives full control over status, headers, cookies, and content type.
|
|
163
184
|
|
|
185
|
+
Use plain return values for simple JSON endpoints:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from modmex import BaseModel
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class User(BaseModel):
|
|
192
|
+
id: int
|
|
193
|
+
name: str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.get("/users/<user_id>")
|
|
197
|
+
def get_user(user_id: int):
|
|
198
|
+
user = User(id=user_id, name="Ada")
|
|
199
|
+
return user.model_dump()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.post("/users", status_code=201)
|
|
203
|
+
def create_user():
|
|
204
|
+
user = User(id=42, name="Ada")
|
|
205
|
+
return user.model_dump()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.delete("/users/<user_id>")
|
|
209
|
+
def delete_user(user_id: int):
|
|
210
|
+
return {"deleted": user_id}, 202
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Use `Response` when the endpoint needs explicit response metadata. If you are
|
|
214
|
+
returning the same `User` model, pass `user.model_dump_json()` as the body and
|
|
215
|
+
set `content_type="application/json"`:
|
|
216
|
+
|
|
164
217
|
```python
|
|
165
218
|
from modmex_lambda import Response
|
|
166
219
|
from modmex_lambda.shared.cookies import Cookie
|
|
@@ -168,13 +221,63 @@ from modmex_lambda.shared.cookies import Cookie
|
|
|
168
221
|
|
|
169
222
|
@app.get("/session")
|
|
170
223
|
def session():
|
|
224
|
+
user = User(id=42, name="Ada")
|
|
171
225
|
return Response(
|
|
172
226
|
status_code=200,
|
|
173
|
-
|
|
174
|
-
|
|
227
|
+
content_type="application/json",
|
|
228
|
+
body=user.model_dump_json(),
|
|
229
|
+
headers={"x-app": "users"},
|
|
230
|
+
cookies=[
|
|
231
|
+
Cookie(
|
|
232
|
+
"session",
|
|
233
|
+
"abc",
|
|
234
|
+
path="/",
|
|
235
|
+
http_only=True,
|
|
236
|
+
secure=True,
|
|
237
|
+
max_age=3600,
|
|
238
|
+
),
|
|
239
|
+
],
|
|
175
240
|
)
|
|
176
241
|
```
|
|
177
242
|
|
|
243
|
+
`Response` accepts:
|
|
244
|
+
|
|
245
|
+
- `status_code`: the HTTP status code returned to API Gateway.
|
|
246
|
+
- `body`: a JSON-serializable object, `str`, `bytes`, or `None`.
|
|
247
|
+
- `content_type`: sets `Content-Type` unless the header is already present.
|
|
248
|
+
- `headers`: a mapping of header names to a string or list of strings.
|
|
249
|
+
- `cookies`: a list of `Cookie` objects.
|
|
250
|
+
- `compress`: overrides route-level gzip compression for that response.
|
|
251
|
+
|
|
252
|
+
When `Content-Type` starts with `application/json`, non-string bodies are
|
|
253
|
+
serialized with the app serializer. Binary bodies are base64 encoded.
|
|
254
|
+
|
|
255
|
+
For `modmex` models, prefer `model_dump()` when returning plain JSON objects.
|
|
256
|
+
Use `model_dump_json()` when you already need to build a `Response` and want to
|
|
257
|
+
send the serialized JSON string directly with `content_type="application/json"`.
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
@app.get("/avatar/<user_id>")
|
|
261
|
+
def avatar(user_id: int):
|
|
262
|
+
image_bytes = load_avatar(user_id)
|
|
263
|
+
return Response(
|
|
264
|
+
status_code=200,
|
|
265
|
+
content_type="image/png",
|
|
266
|
+
body=image_bytes,
|
|
267
|
+
headers={"Cache-Control": "max-age=3600"},
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Route options can add response behavior without constructing `Response` in every
|
|
272
|
+
handler:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
@app.get("/report", cache_control="max-age=60", compress=True)
|
|
276
|
+
def report():
|
|
277
|
+
return {"items": build_report()}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Compression is applied only when the request includes `Accept-Encoding: gzip`.
|
|
178
281
|
REST API responses use `multiValueHeaders`; HTTP API responses use the v2
|
|
179
282
|
`headers` and `cookies` shape.
|
|
180
283
|
|
|
@@ -407,8 +510,69 @@ def lambda_handler(event: APIGatewayHttpEvent, context):
|
|
|
407
510
|
|
|
408
511
|
## Validation
|
|
409
512
|
|
|
410
|
-
Modmex is the default validation and coercion engine.
|
|
411
|
-
|
|
513
|
+
Modmex is the default validation and coercion engine. It is used for path,
|
|
514
|
+
query, header, cookie, and body parameters declared with `Annotated`, and it is
|
|
515
|
+
paired with the default JSON serializer for common values like enums, dates,
|
|
516
|
+
datetimes, decimals, and dataclasses.
|
|
517
|
+
|
|
518
|
+
```python
|
|
519
|
+
from datetime import date
|
|
520
|
+
from decimal import Decimal
|
|
521
|
+
from enum import Enum
|
|
522
|
+
from typing import Annotated
|
|
523
|
+
|
|
524
|
+
from modmex import BaseModel
|
|
525
|
+
from modmex_lambda import ApiGatewayHttpResolver
|
|
526
|
+
from modmex_lambda.event_handler.params import Body, Path, Query
|
|
527
|
+
|
|
528
|
+
app = ApiGatewayHttpResolver()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class Plan(str, Enum):
|
|
532
|
+
FREE = "free"
|
|
533
|
+
PRO = "pro"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class CreateAccount(BaseModel):
|
|
537
|
+
name: str
|
|
538
|
+
plan: Plan = Plan.FREE
|
|
539
|
+
trial_ends_on: date | None = None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class Account(BaseModel):
|
|
543
|
+
id: int
|
|
544
|
+
name: str
|
|
545
|
+
plan: Plan
|
|
546
|
+
balance: Decimal
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@app.post("/accounts", status_code=201)
|
|
550
|
+
def create_account(payload: Annotated[CreateAccount, Body()]):
|
|
551
|
+
account = Account(
|
|
552
|
+
id=42,
|
|
553
|
+
name=payload.name,
|
|
554
|
+
plan=payload.plan,
|
|
555
|
+
balance=Decimal("0.00"),
|
|
556
|
+
)
|
|
557
|
+
# Return model_dump() when you want the response body to be a JSON object.
|
|
558
|
+
return account.model_dump()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@app.get("/accounts/<account_id>")
|
|
562
|
+
def get_account(
|
|
563
|
+
account_id: Annotated[int, Path()],
|
|
564
|
+
include_usage: Annotated[bool, Query()] = False,
|
|
565
|
+
):
|
|
566
|
+
return {
|
|
567
|
+
"id": account_id,
|
|
568
|
+
"include_usage": include_usage,
|
|
569
|
+
"created_on": date(2026, 1, 1),
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
If validation fails, the resolver returns `400` with a compact validation error
|
|
574
|
+
payload. For domain-specific errors, register an exception handler and return a
|
|
575
|
+
`Response` with the shape your API expects.
|
|
412
576
|
|
|
413
577
|
## Logging
|
|
414
578
|
|
|
@@ -426,18 +590,6 @@ def lambda_handler(event, context):
|
|
|
426
590
|
The logger emits structured JSON and can extract Lambda request IDs and API
|
|
427
591
|
Gateway correlation IDs.
|
|
428
592
|
|
|
429
|
-
## Benchmarks
|
|
430
|
-
|
|
431
|
-
The benchmark suite lives in `.benchmark/api_gateway_benchmark.py`.
|
|
432
|
-
|
|
433
|
-
It covers cold imports, app setup, route registration, API Gateway v1/v2
|
|
434
|
-
invocation, `event_parser`, `event_source`, and logger hot paths.
|
|
435
|
-
|
|
436
|
-
```bash
|
|
437
|
-
poetry run python .benchmark/api_gateway_benchmark.py
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
More details are in `.benchmark/README.md`.
|
|
441
593
|
|
|
442
594
|
## Limitations
|
|
443
595
|
|
|
@@ -45,7 +45,9 @@ def ping():
|
|
|
45
45
|
return {"message": "pong"}
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
handler
|
|
48
|
+
def handler(event, context):
|
|
49
|
+
return app.resolve(event, context)
|
|
50
|
+
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
The internal base resolver is intentionally not exported from the package root;
|
|
@@ -69,6 +71,25 @@ def create_user():
|
|
|
69
71
|
Supported route decorators include `get`, `post`, `put`, `patch`, `delete`,
|
|
70
72
|
`options`, and `any`.
|
|
71
73
|
|
|
74
|
+
You can also declare routes on a standalone router and include it in the
|
|
75
|
+
resolver:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from modmex_lambda import ApiGatewayHttpResolver
|
|
79
|
+
from modmex_lambda.routing import Router
|
|
80
|
+
|
|
81
|
+
app = ApiGatewayHttpResolver()
|
|
82
|
+
router = Router()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@router.get("/health")
|
|
86
|
+
def health():
|
|
87
|
+
return {"ok": True}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
app.include_router(router)
|
|
91
|
+
```
|
|
92
|
+
|
|
72
93
|
Routers can also strip deployment prefixes:
|
|
73
94
|
|
|
74
95
|
```python
|
|
@@ -146,6 +167,38 @@ Route return values are converted to API Gateway proxy responses:
|
|
|
146
167
|
- `(body, status_code)` sets the response status.
|
|
147
168
|
- `Response` gives full control over status, headers, cookies, and content type.
|
|
148
169
|
|
|
170
|
+
Use plain return values for simple JSON endpoints:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from modmex import BaseModel
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class User(BaseModel):
|
|
177
|
+
id: int
|
|
178
|
+
name: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.get("/users/<user_id>")
|
|
182
|
+
def get_user(user_id: int):
|
|
183
|
+
user = User(id=user_id, name="Ada")
|
|
184
|
+
return user.model_dump()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.post("/users", status_code=201)
|
|
188
|
+
def create_user():
|
|
189
|
+
user = User(id=42, name="Ada")
|
|
190
|
+
return user.model_dump()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.delete("/users/<user_id>")
|
|
194
|
+
def delete_user(user_id: int):
|
|
195
|
+
return {"deleted": user_id}, 202
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Use `Response` when the endpoint needs explicit response metadata. If you are
|
|
199
|
+
returning the same `User` model, pass `user.model_dump_json()` as the body and
|
|
200
|
+
set `content_type="application/json"`:
|
|
201
|
+
|
|
149
202
|
```python
|
|
150
203
|
from modmex_lambda import Response
|
|
151
204
|
from modmex_lambda.shared.cookies import Cookie
|
|
@@ -153,13 +206,63 @@ from modmex_lambda.shared.cookies import Cookie
|
|
|
153
206
|
|
|
154
207
|
@app.get("/session")
|
|
155
208
|
def session():
|
|
209
|
+
user = User(id=42, name="Ada")
|
|
156
210
|
return Response(
|
|
157
211
|
status_code=200,
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
content_type="application/json",
|
|
213
|
+
body=user.model_dump_json(),
|
|
214
|
+
headers={"x-app": "users"},
|
|
215
|
+
cookies=[
|
|
216
|
+
Cookie(
|
|
217
|
+
"session",
|
|
218
|
+
"abc",
|
|
219
|
+
path="/",
|
|
220
|
+
http_only=True,
|
|
221
|
+
secure=True,
|
|
222
|
+
max_age=3600,
|
|
223
|
+
),
|
|
224
|
+
],
|
|
160
225
|
)
|
|
161
226
|
```
|
|
162
227
|
|
|
228
|
+
`Response` accepts:
|
|
229
|
+
|
|
230
|
+
- `status_code`: the HTTP status code returned to API Gateway.
|
|
231
|
+
- `body`: a JSON-serializable object, `str`, `bytes`, or `None`.
|
|
232
|
+
- `content_type`: sets `Content-Type` unless the header is already present.
|
|
233
|
+
- `headers`: a mapping of header names to a string or list of strings.
|
|
234
|
+
- `cookies`: a list of `Cookie` objects.
|
|
235
|
+
- `compress`: overrides route-level gzip compression for that response.
|
|
236
|
+
|
|
237
|
+
When `Content-Type` starts with `application/json`, non-string bodies are
|
|
238
|
+
serialized with the app serializer. Binary bodies are base64 encoded.
|
|
239
|
+
|
|
240
|
+
For `modmex` models, prefer `model_dump()` when returning plain JSON objects.
|
|
241
|
+
Use `model_dump_json()` when you already need to build a `Response` and want to
|
|
242
|
+
send the serialized JSON string directly with `content_type="application/json"`.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
@app.get("/avatar/<user_id>")
|
|
246
|
+
def avatar(user_id: int):
|
|
247
|
+
image_bytes = load_avatar(user_id)
|
|
248
|
+
return Response(
|
|
249
|
+
status_code=200,
|
|
250
|
+
content_type="image/png",
|
|
251
|
+
body=image_bytes,
|
|
252
|
+
headers={"Cache-Control": "max-age=3600"},
|
|
253
|
+
)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Route options can add response behavior without constructing `Response` in every
|
|
257
|
+
handler:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
@app.get("/report", cache_control="max-age=60", compress=True)
|
|
261
|
+
def report():
|
|
262
|
+
return {"items": build_report()}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Compression is applied only when the request includes `Accept-Encoding: gzip`.
|
|
163
266
|
REST API responses use `multiValueHeaders`; HTTP API responses use the v2
|
|
164
267
|
`headers` and `cookies` shape.
|
|
165
268
|
|
|
@@ -392,8 +495,69 @@ def lambda_handler(event: APIGatewayHttpEvent, context):
|
|
|
392
495
|
|
|
393
496
|
## Validation
|
|
394
497
|
|
|
395
|
-
Modmex is the default validation and coercion engine.
|
|
396
|
-
|
|
498
|
+
Modmex is the default validation and coercion engine. It is used for path,
|
|
499
|
+
query, header, cookie, and body parameters declared with `Annotated`, and it is
|
|
500
|
+
paired with the default JSON serializer for common values like enums, dates,
|
|
501
|
+
datetimes, decimals, and dataclasses.
|
|
502
|
+
|
|
503
|
+
```python
|
|
504
|
+
from datetime import date
|
|
505
|
+
from decimal import Decimal
|
|
506
|
+
from enum import Enum
|
|
507
|
+
from typing import Annotated
|
|
508
|
+
|
|
509
|
+
from modmex import BaseModel
|
|
510
|
+
from modmex_lambda import ApiGatewayHttpResolver
|
|
511
|
+
from modmex_lambda.event_handler.params import Body, Path, Query
|
|
512
|
+
|
|
513
|
+
app = ApiGatewayHttpResolver()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class Plan(str, Enum):
|
|
517
|
+
FREE = "free"
|
|
518
|
+
PRO = "pro"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class CreateAccount(BaseModel):
|
|
522
|
+
name: str
|
|
523
|
+
plan: Plan = Plan.FREE
|
|
524
|
+
trial_ends_on: date | None = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class Account(BaseModel):
|
|
528
|
+
id: int
|
|
529
|
+
name: str
|
|
530
|
+
plan: Plan
|
|
531
|
+
balance: Decimal
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@app.post("/accounts", status_code=201)
|
|
535
|
+
def create_account(payload: Annotated[CreateAccount, Body()]):
|
|
536
|
+
account = Account(
|
|
537
|
+
id=42,
|
|
538
|
+
name=payload.name,
|
|
539
|
+
plan=payload.plan,
|
|
540
|
+
balance=Decimal("0.00"),
|
|
541
|
+
)
|
|
542
|
+
# Return model_dump() when you want the response body to be a JSON object.
|
|
543
|
+
return account.model_dump()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@app.get("/accounts/<account_id>")
|
|
547
|
+
def get_account(
|
|
548
|
+
account_id: Annotated[int, Path()],
|
|
549
|
+
include_usage: Annotated[bool, Query()] = False,
|
|
550
|
+
):
|
|
551
|
+
return {
|
|
552
|
+
"id": account_id,
|
|
553
|
+
"include_usage": include_usage,
|
|
554
|
+
"created_on": date(2026, 1, 1),
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
If validation fails, the resolver returns `400` with a compact validation error
|
|
559
|
+
payload. For domain-specific errors, register an exception handler and return a
|
|
560
|
+
`Response` with the shape your API expects.
|
|
397
561
|
|
|
398
562
|
## Logging
|
|
399
563
|
|
|
@@ -411,18 +575,6 @@ def lambda_handler(event, context):
|
|
|
411
575
|
The logger emits structured JSON and can extract Lambda request IDs and API
|
|
412
576
|
Gateway correlation IDs.
|
|
413
577
|
|
|
414
|
-
## Benchmarks
|
|
415
|
-
|
|
416
|
-
The benchmark suite lives in `.benchmark/api_gateway_benchmark.py`.
|
|
417
|
-
|
|
418
|
-
It covers cold imports, app setup, route registration, API Gateway v1/v2
|
|
419
|
-
invocation, `event_parser`, `event_source`, and logger hot paths.
|
|
420
|
-
|
|
421
|
-
```bash
|
|
422
|
-
poetry run python .benchmark/api_gateway_benchmark.py
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
More details are in `.benchmark/README.md`.
|
|
426
578
|
|
|
427
579
|
## Limitations
|
|
428
580
|
|
|
@@ -190,8 +190,50 @@ class HasRoutes(ABC):
|
|
|
190
190
|
cache_control=cache_control,
|
|
191
191
|
)
|
|
192
192
|
|
|
193
|
+
def options(
|
|
194
|
+
self,
|
|
195
|
+
rule: str,
|
|
196
|
+
description: str | None = None,
|
|
197
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
198
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
199
|
+
cors: bool | None = None,
|
|
200
|
+
compress: bool = False,
|
|
201
|
+
cache_control: str | None = None,
|
|
202
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
203
|
+
return self.route(
|
|
204
|
+
rule=rule,
|
|
205
|
+
method="OPTIONS",
|
|
206
|
+
description=description,
|
|
207
|
+
status_code=status_code,
|
|
208
|
+
middlewares=middlewares,
|
|
209
|
+
cors=cors,
|
|
210
|
+
compress=compress,
|
|
211
|
+
cache_control=cache_control,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def any(
|
|
215
|
+
self,
|
|
216
|
+
rule: str,
|
|
217
|
+
description: str | None = None,
|
|
218
|
+
status_code: int = DEFAULT_STATUS_CODE,
|
|
219
|
+
middlewares: list[AnyCallableT] | None = None,
|
|
220
|
+
cors: bool | None = None,
|
|
221
|
+
compress: bool = False,
|
|
222
|
+
cache_control: str | None = None,
|
|
223
|
+
) -> Callable[[AnyCallableT], AnyCallableT]:
|
|
224
|
+
return self.route(
|
|
225
|
+
rule=rule,
|
|
226
|
+
method="ANY",
|
|
227
|
+
description=description,
|
|
228
|
+
status_code=status_code,
|
|
229
|
+
middlewares=middlewares,
|
|
230
|
+
cors=cors,
|
|
231
|
+
compress=compress,
|
|
232
|
+
cache_control=cache_control,
|
|
233
|
+
)
|
|
234
|
+
|
|
193
235
|
|
|
194
|
-
class IRouter(ABC):
|
|
236
|
+
class IRouter(HasRoutes, ABC):
|
|
195
237
|
_routes: list[IRoute]
|
|
196
238
|
|
|
197
239
|
@abstractmethod
|
|
@@ -281,7 +281,7 @@ def test_include_router_and_strip_prefixes() -> None:
|
|
|
281
281
|
router = Router()
|
|
282
282
|
app = ApiGatewayHttpResolver(strip_prefixes=["/prod"])
|
|
283
283
|
|
|
284
|
-
@router.
|
|
284
|
+
@router.get("/health")
|
|
285
285
|
def health():
|
|
286
286
|
return {"ok": True}
|
|
287
287
|
|
|
@@ -292,6 +292,25 @@ def test_include_router_and_strip_prefixes() -> None:
|
|
|
292
292
|
assert response_body(response) == {"ok": True}
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
def test_include_nested_routers() -> None:
|
|
296
|
+
grandchild = Router()
|
|
297
|
+
child = Router()
|
|
298
|
+
parent = Router()
|
|
299
|
+
app = ApiGatewayHttpResolver()
|
|
300
|
+
|
|
301
|
+
@grandchild.get("/health")
|
|
302
|
+
def health():
|
|
303
|
+
return {"ok": True}
|
|
304
|
+
|
|
305
|
+
child.include_router(grandchild, prefix="/v1")
|
|
306
|
+
parent.include_router(child, prefix="/api")
|
|
307
|
+
app.include_router(parent)
|
|
308
|
+
|
|
309
|
+
response = app.resolve(http_v2_event("GET", "/api/v1/health"), object())
|
|
310
|
+
|
|
311
|
+
assert response_body(response) == {"ok": True}
|
|
312
|
+
|
|
313
|
+
|
|
295
314
|
def test_strip_prefix_exact_match_and_regex_prefix() -> None:
|
|
296
315
|
app = ApiGatewayHttpResolver(strip_prefixes=["/prod"])
|
|
297
316
|
regex_app = ApiGatewayHttpResolver(strip_prefixes=[re.compile(r"^/v[0-9]+")])
|
|
@@ -44,7 +44,7 @@ def test_router_matches_any_routes_for_static_paths() -> None:
|
|
|
44
44
|
def handler():
|
|
45
45
|
return {"ok": True}
|
|
46
46
|
|
|
47
|
-
router.
|
|
47
|
+
router.any("/proxy")(handler)
|
|
48
48
|
|
|
49
49
|
route, params, allowed_methods = router.match("PATCH", "/proxy")
|
|
50
50
|
|
|
@@ -69,3 +69,23 @@ def test_router_include_router_preserves_route_metadata() -> None:
|
|
|
69
69
|
assert route.handler is handler
|
|
70
70
|
assert route.cache_control == "max-age=60"
|
|
71
71
|
assert route.compress is True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_nested_routers_accumulate_prefixes() -> None:
|
|
75
|
+
grandchild = Router()
|
|
76
|
+
child = Router()
|
|
77
|
+
parent = Router()
|
|
78
|
+
|
|
79
|
+
@grandchild.get("/health")
|
|
80
|
+
def health():
|
|
81
|
+
return {"ok": True}
|
|
82
|
+
|
|
83
|
+
child.include_router(grandchild, prefix="/v1")
|
|
84
|
+
parent.include_router(child, prefix="/api")
|
|
85
|
+
|
|
86
|
+
route, params, allowed_methods = parent.match("GET", "/api/v1/health")
|
|
87
|
+
|
|
88
|
+
assert route is not None
|
|
89
|
+
assert route.handler is health
|
|
90
|
+
assert params == {}
|
|
91
|
+
assert allowed_methods == set()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_proxy_event.py
RENAMED
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/cognito_user_pool_event.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/__init__.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/compat.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/dependant.py
RENAMED
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/depends.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/params.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/types.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/exception_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/routing_fallbacks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_api_gateway_proxy_event.py
RENAMED
|
File without changes
|
{modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_cognito_user_pool_event.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|