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.
Files changed (72) hide show
  1. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/PKG-INFO +170 -18
  2. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/README.md +169 -17
  3. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/routing.py +43 -1
  4. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/pyproject.toml +1 -1
  5. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_api_gateway.py +20 -1
  6. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_routing.py +21 -1
  7. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.github/workflows/ci.yml +0 -0
  8. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.github/workflows/release.yml +0 -0
  9. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/.gitignore +0 -0
  10. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/LICENSE +0 -0
  11. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/__init__.py +0 -0
  12. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/__init__.py +0 -0
  13. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_authorizer_event.py +0 -0
  14. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_proxy_event.py +0 -0
  15. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/api_gateway_websocket_event.py +0 -0
  16. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/cognito_user_pool_event.py +0 -0
  17. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/data_classes/common.py +0 -0
  18. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/__init__.py +0 -0
  19. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/api_gateway.py +0 -0
  20. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/constants.py +0 -0
  21. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/content_types.py +0 -0
  22. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/cors.py +0 -0
  23. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/__init__.py +0 -0
  24. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/compat.py +0 -0
  25. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/dependant.py +0 -0
  26. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/dependency_middleware.py +0 -0
  27. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/depends.py +0 -0
  28. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/params.py +0 -0
  29. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/dependencies/types.py +0 -0
  30. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/exception_handler.py +0 -0
  31. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/exceptions.py +0 -0
  32. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/gateway_response.py +0 -0
  33. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/middlewares.py +0 -0
  34. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/params.py +0 -0
  35. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/request.py +0 -0
  36. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/response.py +0 -0
  37. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/routing_fallbacks.py +0 -0
  38. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_handler/types.py +0 -0
  39. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/event_sources.py +0 -0
  40. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/exceptions.py +0 -0
  41. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/logging.py +0 -0
  42. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/params.py +0 -0
  43. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/parser.py +0 -0
  44. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/request.py +0 -0
  45. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/resolver.py +0 -0
  46. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/response.py +0 -0
  47. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/routing.py +0 -0
  48. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/__init__.py +0 -0
  49. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/cookies.py +0 -0
  50. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/headers_serializer.py +0 -0
  51. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/json_encoder.py +0 -0
  52. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/shared/types.py +0 -0
  53. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/modmex_lambda/validation.py +0 -0
  54. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/poetry.lock +0 -0
  55. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/__init__.py +0 -0
  56. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/conftest.py +0 -0
  57. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_api_gateway_proxy_event.py +0 -0
  58. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_cognito_user_pool_event.py +0 -0
  59. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/data_classes/test_common.py +0 -0
  60. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/__init__.py +0 -0
  61. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_cors.py +0 -0
  62. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_dependencies.py +0 -0
  63. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_exception_handler.py +0 -0
  64. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_gateway_response.py +0 -0
  65. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_request.py +0 -0
  66. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/event_handler/test_response.py +0 -0
  67. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/shared/test_cookies_headers.py +0 -0
  68. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/shared/test_json_encoder.py +0 -0
  69. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_logging.py +0 -0
  70. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_parser_event_sources.py +0 -0
  71. {modmex_lambda-0.2.0 → modmex_lambda-0.3.0}/tests/test_reexports.py +0 -0
  72. {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.2.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 = app.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
- body={"ok": True},
174
- cookies=[Cookie("seen", "true", secure=True)],
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. Pydantic is not required
411
- for normal operation.
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 = app.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
- body={"ok": True},
159
- cookies=[Cookie("seen", "true", secure=True)],
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. Pydantic is not required
396
- for normal operation.
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "modmex-lambda"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Ultra-lightweight AWS Lambda utilities for API Gateway routing and event handling."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.route("/health", method="GET")
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.route("/proxy", method="ANY")(handler)
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