aws-lambda-powertools 3.0.0.dev14__py3-none-any.whl → 3.0.1a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. aws_lambda_powertools/event_handler/api_gateway.py +227 -54
  2. aws_lambda_powertools/event_handler/appsync.py +343 -97
  3. aws_lambda_powertools/event_handler/bedrock_agent.py +121 -0
  4. aws_lambda_powertools/event_handler/exceptions.py +1 -1
  5. aws_lambda_powertools/event_handler/graphql_appsync/__init__.py +0 -0
  6. aws_lambda_powertools/event_handler/graphql_appsync/_registry.py +78 -0
  7. aws_lambda_powertools/event_handler/graphql_appsync/base.py +160 -0
  8. aws_lambda_powertools/event_handler/graphql_appsync/exceptions.py +10 -0
  9. aws_lambda_powertools/event_handler/graphql_appsync/router.py +62 -0
  10. aws_lambda_powertools/event_handler/middlewares/openapi_validation.py +1 -1
  11. aws_lambda_powertools/event_handler/openapi/compat.py +4 -7
  12. aws_lambda_powertools/event_handler/openapi/encoders.py +94 -70
  13. aws_lambda_powertools/event_handler/openapi/exceptions.py +12 -0
  14. aws_lambda_powertools/event_handler/openapi/models.py +38 -5
  15. aws_lambda_powertools/event_handler/openapi/swagger_ui/html.py +1 -4
  16. aws_lambda_powertools/event_handler/openapi/types.py +8 -6
  17. aws_lambda_powertools/event_handler/util.py +55 -2
  18. aws_lambda_powertools/logging/utils.py +5 -5
  19. aws_lambda_powertools/metrics/provider/cloudwatch_emf/metric_properties.py +1 -0
  20. aws_lambda_powertools/shared/cookies.py +2 -2
  21. aws_lambda_powertools/shared/dynamodb_deserializer.py +13 -0
  22. aws_lambda_powertools/shared/version.py +1 -1
  23. aws_lambda_powertools/tracing/tracer.py +4 -2
  24. aws_lambda_powertools/utilities/batch/base.py +13 -4
  25. aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py +260 -1
  26. aws_lambda_powertools/utilities/data_classes/kafka_event.py +2 -2
  27. aws_lambda_powertools/utilities/data_classes/vpc_lattice.py +7 -0
  28. aws_lambda_powertools/utilities/data_masking/base.py +4 -4
  29. aws_lambda_powertools/utilities/feature_flags/appconfig.py +21 -2
  30. aws_lambda_powertools/utilities/idempotency/idempotency.py +22 -2
  31. aws_lambda_powertools/utilities/idempotency/persistence/base.py +4 -1
  32. aws_lambda_powertools/utilities/parameters/secrets.py +1 -1
  33. aws_lambda_powertools/utilities/parser/models/__init__.py +6 -0
  34. aws_lambda_powertools/utilities/parser/models/apigw.py +12 -1
  35. aws_lambda_powertools/utilities/parser/models/apigwv2.py +7 -1
  36. aws_lambda_powertools/utilities/validation/base.py +27 -2
  37. aws_lambda_powertools/utilities/validation/validator.py +49 -4
  38. {aws_lambda_powertools-3.0.0.dev14.dist-info → aws_lambda_powertools-3.0.1a0.dist-info}/METADATA +12 -2
  39. {aws_lambda_powertools-3.0.0.dev14.dist-info → aws_lambda_powertools-3.0.1a0.dist-info}/RECORD +41 -36
  40. {aws_lambda_powertools-3.0.0.dev14.dist-info → aws_lambda_powertools-3.0.1a0.dist-info}/LICENSE +0 -0
  41. {aws_lambda_powertools-3.0.0.dev14.dist-info → aws_lambda_powertools-3.0.1a0.dist-info}/WHEEL +0 -0
@@ -14,10 +14,12 @@ from http import HTTPStatus
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Mapping, Match, Pattern, Sequence, TypeVar, cast
16
16
 
17
+ from typing_extensions import override
18
+
17
19
  from aws_lambda_powertools.event_handler import content_types
18
20
  from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
19
21
  from aws_lambda_powertools.event_handler.openapi.constants import DEFAULT_API_VERSION, DEFAULT_OPENAPI_VERSION
20
- from aws_lambda_powertools.event_handler.openapi.exceptions import RequestValidationError
22
+ from aws_lambda_powertools.event_handler.openapi.exceptions import RequestValidationError, SchemaValidationError
21
23
  from aws_lambda_powertools.event_handler.openapi.types import (
22
24
  COMPONENT_REF_PREFIX,
23
25
  METHODS_WITH_BODY,
@@ -27,7 +29,13 @@ from aws_lambda_powertools.event_handler.openapi.types import (
27
29
  validation_error_definition,
28
30
  validation_error_response_definition,
29
31
  )
30
- from aws_lambda_powertools.event_handler.util import _FrozenDict, extract_origin_header
32
+ from aws_lambda_powertools.event_handler.util import (
33
+ _FrozenDict,
34
+ _FrozenListDict,
35
+ _validate_openapi_security_parameters,
36
+ extract_origin_header,
37
+ )
38
+ from aws_lambda_powertools.shared.cookies import Cookie
31
39
  from aws_lambda_powertools.shared.functions import powertools_dev_is_set
32
40
  from aws_lambda_powertools.shared.json_encoder import Encoder
33
41
  from aws_lambda_powertools.utilities.data_classes import (
@@ -96,20 +104,22 @@ class CORSConfig:
96
104
  Examples
97
105
  --------
98
106
 
99
- Simple cors example using the default permissive cors, not this should only be used during early prototyping
107
+ Simple CORS example using the default permissive CORS, note that this should only be used during early prototyping.
100
108
 
101
109
  ```python
102
- from aws_lambda_powertools.event_handler import APIGatewayRestResolver
110
+ from aws_lambda_powertools.event_handler.api_gateway import (
111
+ APIGatewayRestResolver, CORSConfig
112
+ )
103
113
 
104
- app = APIGatewayRestResolver()
114
+ app = APIGatewayRestResolver(cors=CORSConfig())
105
115
 
106
- @app.get("/my/path", cors=True)
116
+ @app.get("/my/path")
107
117
  def with_cors():
108
118
  return {"message": "Foo"}
109
119
  ```
110
120
 
111
121
  Using a custom CORSConfig where `with_cors` used the custom provided CORSConfig and `without_cors`
112
- do not include any cors headers.
122
+ do not include any CORS headers.
113
123
 
114
124
  ```python
115
125
  from aws_lambda_powertools.event_handler.api_gateway import (
@@ -166,9 +176,12 @@ class CORSConfig:
166
176
  allow_credentials: bool
167
177
  A boolean value that sets the value of `Access-Control-Allow-Credentials`
168
178
  """
179
+
169
180
  self._allowed_origins = [allow_origin]
181
+
170
182
  if extra_origins:
171
183
  self._allowed_origins.extend(extra_origins)
184
+
172
185
  self.allow_headers = set(self._REQUIRED_HEADERS + (allow_headers or []))
173
186
  self.expose_headers = expose_headers or []
174
187
  self.max_age = max_age
@@ -189,17 +202,42 @@ class CORSConfig:
189
202
  # The origin matched an allowed origin, so return the CORS headers
190
203
  headers = {
191
204
  "Access-Control-Allow-Origin": origin,
192
- "Access-Control-Allow-Headers": ",".join(sorted(self.allow_headers)),
205
+ "Access-Control-Allow-Headers": CORSConfig.build_allow_methods(self.allow_headers),
193
206
  }
194
207
 
195
208
  if self.expose_headers:
196
209
  headers["Access-Control-Expose-Headers"] = ",".join(self.expose_headers)
197
210
  if self.max_age is not None:
198
211
  headers["Access-Control-Max-Age"] = str(self.max_age)
199
- if self.allow_credentials is True:
212
+ if origin != "*" and self.allow_credentials is True:
200
213
  headers["Access-Control-Allow-Credentials"] = "true"
201
214
  return headers
202
215
 
216
+ def allowed_origin(self, extracted_origin: str) -> str | None:
217
+ if extracted_origin in self._allowed_origins:
218
+ return extracted_origin
219
+ if extracted_origin is not None and "*" in self._allowed_origins:
220
+ return "*"
221
+
222
+ return None
223
+
224
+ @staticmethod
225
+ def build_allow_methods(methods: set[str]) -> str:
226
+ """Build sorted comma delimited methods for Access-Control-Allow-Methods header
227
+
228
+ Parameters
229
+ ----------
230
+ methods : set[str]
231
+ Set of HTTP Methods
232
+
233
+ Returns
234
+ -------
235
+ set[str]
236
+ Formatted string with all HTTP Methods allowed for CORS e.g., `GET, OPTIONS`
237
+
238
+ """
239
+ return ",".join(sorted(methods))
240
+
203
241
 
204
242
  class Response(Generic[ResponseT]):
205
243
  """Response data class that provides greater control over what is returned from the proxy event"""
@@ -260,16 +298,17 @@ class Route:
260
298
  func: Callable,
261
299
  cors: bool,
262
300
  compress: bool,
263
- cache_control: str | None,
264
- summary: str | None,
265
- description: str | None,
266
- responses: dict[int, OpenAPIResponse] | None,
267
- response_description: str | None,
268
- tags: list[str] | None,
269
- operation_id: str | None,
270
- include_in_schema: bool,
271
- security: list[dict[str, list[str]]] | None,
272
- middlewares: list[Callable[..., Response]] | None,
301
+ cache_control: str | None = None,
302
+ summary: str | None = None,
303
+ description: str | None = None,
304
+ responses: dict[int, OpenAPIResponse] | None = None,
305
+ response_description: str | None = None,
306
+ tags: list[str] | None = None,
307
+ operation_id: str | None = None,
308
+ include_in_schema: bool = True,
309
+ security: list[dict[str, list[str]]] | None = None,
310
+ openapi_extensions: dict[str, Any] | None = None,
311
+ middlewares: list[Callable[..., Response]] | None = None,
273
312
  ):
274
313
  """
275
314
 
@@ -306,6 +345,8 @@ class Route:
306
345
  Whether or not to include this route in the OpenAPI schema
307
346
  security: list[dict[str, list[str]]], optional
308
347
  The OpenAPI security for this route
348
+ openapi_extensions: dict[str, Any], optional
349
+ Additional OpenAPI extensions as a dictionary.
309
350
  middlewares: list[Callable[..., Response]] | None
310
351
  The list of route middlewares to be called in order.
311
352
  """
@@ -329,6 +370,7 @@ class Route:
329
370
  self.tags = tags or []
330
371
  self.include_in_schema = include_in_schema
331
372
  self.security = security
373
+ self.openapi_extensions = openapi_extensions
332
374
  self.middlewares = middlewares or []
333
375
  self.operation_id = operation_id or self._generate_operation_id()
334
376
 
@@ -480,6 +522,10 @@ class Route:
480
522
  if self.security:
481
523
  operation["security"] = self.security
482
524
 
525
+ # Add OpenAPI extensions if present
526
+ if self.openapi_extensions:
527
+ operation.update(self.openapi_extensions)
528
+
483
529
  # Add the parameters to the OpenAPI operation
484
530
  if parameters:
485
531
  all_parameters = {(param["in"], param["name"]): param for param in parameters}
@@ -762,7 +808,10 @@ class ResponseBuilder(Generic[ResponseEventT]):
762
808
  def _add_cors(self, event: ResponseEventT, cors: CORSConfig):
763
809
  """Update headers to include the configured Access-Control headers"""
764
810
  extracted_origin_header = extract_origin_header(event.resolved_headers_field)
765
- self.response.headers.update(cors.to_dict(extracted_origin_header))
811
+
812
+ origin = cors.allowed_origin(extracted_origin_header)
813
+ if origin is not None:
814
+ self.response.headers.update(cors.to_dict(origin))
766
815
 
767
816
  def _add_cache_control(self, cache_control: str):
768
817
  """Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used."""
@@ -873,6 +922,7 @@ class BaseRouter(ABC):
873
922
  operation_id: str | None = None,
874
923
  include_in_schema: bool = True,
875
924
  security: list[dict[str, list[str]]] | None = None,
925
+ openapi_extensions: dict[str, Any] | None = None,
876
926
  middlewares: list[Callable[..., Any]] | None = None,
877
927
  ):
878
928
  raise NotImplementedError()
@@ -932,6 +982,7 @@ class BaseRouter(ABC):
932
982
  operation_id: str | None = None,
933
983
  include_in_schema: bool = True,
934
984
  security: list[dict[str, list[str]]] | None = None,
985
+ openapi_extensions: dict[str, Any] | None = None,
935
986
  middlewares: list[Callable[..., Any]] | None = None,
936
987
  ):
937
988
  """Get route decorator with GET `method`
@@ -970,6 +1021,7 @@ class BaseRouter(ABC):
970
1021
  operation_id,
971
1022
  include_in_schema,
972
1023
  security,
1024
+ openapi_extensions,
973
1025
  middlewares,
974
1026
  )
975
1027
 
@@ -987,6 +1039,7 @@ class BaseRouter(ABC):
987
1039
  operation_id: str | None = None,
988
1040
  include_in_schema: bool = True,
989
1041
  security: list[dict[str, list[str]]] | None = None,
1042
+ openapi_extensions: dict[str, Any] | None = None,
990
1043
  middlewares: list[Callable[..., Any]] | None = None,
991
1044
  ):
992
1045
  """Post route decorator with POST `method`
@@ -1026,6 +1079,7 @@ class BaseRouter(ABC):
1026
1079
  operation_id,
1027
1080
  include_in_schema,
1028
1081
  security,
1082
+ openapi_extensions,
1029
1083
  middlewares,
1030
1084
  )
1031
1085
 
@@ -1043,6 +1097,7 @@ class BaseRouter(ABC):
1043
1097
  operation_id: str | None = None,
1044
1098
  include_in_schema: bool = True,
1045
1099
  security: list[dict[str, list[str]]] | None = None,
1100
+ openapi_extensions: dict[str, Any] | None = None,
1046
1101
  middlewares: list[Callable[..., Any]] | None = None,
1047
1102
  ):
1048
1103
  """Put route decorator with PUT `method`
@@ -1082,6 +1137,7 @@ class BaseRouter(ABC):
1082
1137
  operation_id,
1083
1138
  include_in_schema,
1084
1139
  security,
1140
+ openapi_extensions,
1085
1141
  middlewares,
1086
1142
  )
1087
1143
 
@@ -1099,6 +1155,7 @@ class BaseRouter(ABC):
1099
1155
  operation_id: str | None = None,
1100
1156
  include_in_schema: bool = True,
1101
1157
  security: list[dict[str, list[str]]] | None = None,
1158
+ openapi_extensions: dict[str, Any] | None = None,
1102
1159
  middlewares: list[Callable[..., Any]] | None = None,
1103
1160
  ):
1104
1161
  """Delete route decorator with DELETE `method`
@@ -1137,6 +1194,7 @@ class BaseRouter(ABC):
1137
1194
  operation_id,
1138
1195
  include_in_schema,
1139
1196
  security,
1197
+ openapi_extensions,
1140
1198
  middlewares,
1141
1199
  )
1142
1200
 
@@ -1154,6 +1212,7 @@ class BaseRouter(ABC):
1154
1212
  operation_id: str | None = None,
1155
1213
  include_in_schema: bool = True,
1156
1214
  security: list[dict[str, list[str]]] | None = None,
1215
+ openapi_extensions: dict[str, Any] | None = None,
1157
1216
  middlewares: list[Callable] | None = None,
1158
1217
  ):
1159
1218
  """Patch route decorator with PATCH `method`
@@ -1195,6 +1254,7 @@ class BaseRouter(ABC):
1195
1254
  operation_id,
1196
1255
  include_in_schema,
1197
1256
  security,
1257
+ openapi_extensions,
1198
1258
  middlewares,
1199
1259
  )
1200
1260
 
@@ -1212,6 +1272,7 @@ class BaseRouter(ABC):
1212
1272
  operation_id: str | None = None,
1213
1273
  include_in_schema: bool = True,
1214
1274
  security: list[dict[str, list[str]]] | None = None,
1275
+ openapi_extensions: dict[str, Any] | None = None,
1215
1276
  middlewares: list[Callable] | None = None,
1216
1277
  ):
1217
1278
  """Head route decorator with HEAD `method`
@@ -1252,6 +1313,7 @@ class BaseRouter(ABC):
1252
1313
  operation_id,
1253
1314
  include_in_schema,
1254
1315
  security,
1316
+ openapi_extensions,
1255
1317
  middlewares,
1256
1318
  )
1257
1319
 
@@ -1372,7 +1434,6 @@ def _registered_api_adapter(app: ApiGatewayResolver, next_middleware: Callable[.
1372
1434
  """
1373
1435
  route_args: dict = app.context.get("_route_args", {})
1374
1436
  logger.debug(f"Calling API Route Handler: {route_args}")
1375
-
1376
1437
  return app._to_response(next_middleware(**route_args))
1377
1438
 
1378
1439
 
@@ -1473,6 +1534,7 @@ class ApiGatewayResolver(BaseRouter):
1473
1534
  license_info: License | None = None,
1474
1535
  security_schemes: dict[str, SecurityScheme] | None = None,
1475
1536
  security: list[dict[str, list[str]]] | None = None,
1537
+ openapi_extensions: dict[str, Any] | None = None,
1476
1538
  ) -> OpenAPI:
1477
1539
  """
1478
1540
  Returns the OpenAPI schema as a pydantic model.
@@ -1503,6 +1565,8 @@ class ApiGatewayResolver(BaseRouter):
1503
1565
  A declaration of the security schemes available to be used in the specification.
1504
1566
  security: list[dict[str, list[str]]], optional
1505
1567
  A declaration of which security mechanisms are applied globally across the API.
1568
+ openapi_extensions: Dict[str, Any], optional
1569
+ Additional OpenAPI extensions as a dictionary.
1506
1570
 
1507
1571
  Returns
1508
1572
  -------
@@ -1535,11 +1599,15 @@ class ApiGatewayResolver(BaseRouter):
1535
1599
 
1536
1600
  info.update({field: value for field, value in optional_fields.items() if value})
1537
1601
 
1602
+ if not isinstance(openapi_extensions, dict):
1603
+ openapi_extensions = {}
1604
+
1538
1605
  output: dict[str, Any] = {
1539
1606
  "openapi": openapi_version,
1540
1607
  "info": info,
1541
1608
  "servers": self._get_openapi_servers(servers),
1542
1609
  "security": self._get_openapi_security(security, security_schemes),
1610
+ **openapi_extensions,
1543
1611
  }
1544
1612
 
1545
1613
  components: dict[str, dict[str, Any]] = {}
@@ -1560,6 +1628,16 @@ class ApiGatewayResolver(BaseRouter):
1560
1628
 
1561
1629
  # Add routes to the OpenAPI schema
1562
1630
  for route in all_routes:
1631
+
1632
+ if route.security and not _validate_openapi_security_parameters(
1633
+ security=route.security,
1634
+ security_schemes=security_schemes,
1635
+ ):
1636
+ raise SchemaValidationError(
1637
+ "Security configuration was not found in security_schemas or security_schema was not defined. "
1638
+ "See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#security-schemes",
1639
+ )
1640
+
1563
1641
  if not route.include_in_schema:
1564
1642
  continue
1565
1643
 
@@ -1605,12 +1683,11 @@ class ApiGatewayResolver(BaseRouter):
1605
1683
  if not security:
1606
1684
  return None
1607
1685
 
1608
- if not security_schemes:
1609
- raise ValueError("security_schemes must be provided if security is provided")
1610
-
1611
- # Check if all keys in security are present in the security_schemes
1612
- if any(key not in security_schemes for sec in security for key in sec):
1613
- raise ValueError("Some security schemes not found in security_schemes")
1686
+ if not _validate_openapi_security_parameters(security=security, security_schemes=security_schemes):
1687
+ raise SchemaValidationError(
1688
+ "Security configuration was not found in security_schemas or security_schema was not defined. "
1689
+ "See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#security-schemes",
1690
+ )
1614
1691
 
1615
1692
  return security
1616
1693
 
@@ -1641,6 +1718,7 @@ class ApiGatewayResolver(BaseRouter):
1641
1718
  license_info: License | None = None,
1642
1719
  security_schemes: dict[str, SecurityScheme] | None = None,
1643
1720
  security: list[dict[str, list[str]]] | None = None,
1721
+ openapi_extensions: dict[str, Any] | None = None,
1644
1722
  ) -> str:
1645
1723
  """
1646
1724
  Returns the OpenAPI schema as a JSON serializable dict
@@ -1671,6 +1749,8 @@ class ApiGatewayResolver(BaseRouter):
1671
1749
  A declaration of the security schemes available to be used in the specification.
1672
1750
  security: list[dict[str, list[str]]], optional
1673
1751
  A declaration of which security mechanisms are applied globally across the API.
1752
+ openapi_extensions: Dict[str, Any], optional
1753
+ Additional OpenAPI extensions as a dictionary.
1674
1754
 
1675
1755
  Returns
1676
1756
  -------
@@ -1693,6 +1773,7 @@ class ApiGatewayResolver(BaseRouter):
1693
1773
  license_info=license_info,
1694
1774
  security_schemes=security_schemes,
1695
1775
  security=security,
1776
+ openapi_extensions=openapi_extensions,
1696
1777
  ),
1697
1778
  by_alias=True,
1698
1779
  exclude_none=True,
@@ -1720,6 +1801,7 @@ class ApiGatewayResolver(BaseRouter):
1720
1801
  security: list[dict[str, list[str]]] | None = None,
1721
1802
  oauth2_config: OAuth2Config | None = None,
1722
1803
  persist_authorization: bool = False,
1804
+ openapi_extensions: dict[str, Any] | None = None,
1723
1805
  ):
1724
1806
  """
1725
1807
  Returns the OpenAPI schema as a JSON serializable dict
@@ -1762,6 +1844,8 @@ class ApiGatewayResolver(BaseRouter):
1762
1844
  The OAuth2 configuration for the Swagger UI.
1763
1845
  persist_authorization: bool, optional
1764
1846
  Whether to persist authorization data on browser close/refresh.
1847
+ openapi_extensions: dict[str, Any], optional
1848
+ Additional OpenAPI extensions as a dictionary.
1765
1849
  """
1766
1850
  from aws_lambda_powertools.event_handler.openapi.compat import model_json
1767
1851
  from aws_lambda_powertools.event_handler.openapi.models import Server
@@ -1811,6 +1895,7 @@ class ApiGatewayResolver(BaseRouter):
1811
1895
  license_info=license_info,
1812
1896
  security_schemes=security_schemes,
1813
1897
  security=security,
1898
+ openapi_extensions=openapi_extensions,
1814
1899
  )
1815
1900
 
1816
1901
  # The .replace('</', '<\\/') part is necessary to prevent a potential issue where the JSON string contains
@@ -1835,7 +1920,6 @@ class ApiGatewayResolver(BaseRouter):
1835
1920
 
1836
1921
  body = generate_swagger_html(
1837
1922
  escaped_spec,
1838
- f"{base_path}{path}",
1839
1923
  swagger_js,
1840
1924
  swagger_css,
1841
1925
  swagger_base_url,
@@ -1864,6 +1948,7 @@ class ApiGatewayResolver(BaseRouter):
1864
1948
  operation_id: str | None = None,
1865
1949
  include_in_schema: bool = True,
1866
1950
  security: list[dict[str, list[str]]] | None = None,
1951
+ openapi_extensions: dict[str, Any] | None = None,
1867
1952
  middlewares: list[Callable[..., Any]] | None = None,
1868
1953
  ):
1869
1954
  """Route decorator includes parameter `method`"""
@@ -1891,6 +1976,7 @@ class ApiGatewayResolver(BaseRouter):
1891
1976
  operation_id,
1892
1977
  include_in_schema,
1893
1978
  security,
1979
+ openapi_extensions,
1894
1980
  middlewares,
1895
1981
  )
1896
1982
 
@@ -1916,6 +2002,36 @@ class ApiGatewayResolver(BaseRouter):
1916
2002
  def resolve(self, event, context) -> dict[str, Any]:
1917
2003
  """Resolves the response based on the provide event and decorator routes
1918
2004
 
2005
+ ## Internals
2006
+
2007
+ Request processing chain is triggered by a Route object being called _(`_call_route` -> `__call__`)_:
2008
+
2009
+ 1. **When a route is matched**
2010
+ 1.1. Exception handlers _(if any exception bubbled up and caught)_
2011
+ 1.2. Global middlewares _(before, and after on the way back)_
2012
+ 1.3. Path level middleware _(before, and after on the way back)_
2013
+ 1.4. Middleware adapter to ensure Response is homogenous (_registered_api_adapter)
2014
+ 1.5. Run actual route
2015
+ 2. **When a route is NOT matched**
2016
+ 2.1. Exception handlers _(if any exception bubbled up and caught)_
2017
+ 2.2. Global middlewares _(before, and after on the way back)_
2018
+ 2.3. Path level middleware _(before, and after on the way back)_
2019
+ 2.4. Middleware adapter to ensure Response is homogenous (_registered_api_adapter)
2020
+ 2.5. Run 404 route handler
2021
+ 3. **When a route is a pre-flight CORS (often not matched)**
2022
+ 3.1. Exception handlers _(if any exception bubbled up and caught)_
2023
+ 3.2. Global middlewares _(before, and after on the way back)_
2024
+ 3.3. Path level middleware _(before, and after on the way back)_
2025
+ 3.4. Middleware adapter to ensure Response is homogenous (_registered_api_adapter)
2026
+ 3.5. Return 204 with appropriate CORS headers
2027
+ 4. **When a route is matched with Data Validation enabled**
2028
+ 4.1. Exception handlers _(if any exception bubbled up and caught)_
2029
+ 4.2. Data Validation middleware _(before, and after on the way back)_
2030
+ 4.3. Global middlewares _(before, and after on the way back)_
2031
+ 4.4. Path level middleware _(before, and after on the way back)_
2032
+ 4.5. Middleware adapter to ensure Response is homogenous (_registered_api_adapter)
2033
+ 4.6. Run actual route
2034
+
1919
2035
  Parameters
1920
2036
  ----------
1921
2037
  event: dict[str, Any]
@@ -2039,7 +2155,9 @@ class ApiGatewayResolver(BaseRouter):
2039
2155
  method = self.current_event.http_method.upper()
2040
2156
  path = self._remove_prefix(self.current_event.path)
2041
2157
 
2042
- for route in self._static_routes + self._dynamic_routes:
2158
+ registered_routes = self._static_routes + self._dynamic_routes
2159
+
2160
+ for route in registered_routes:
2043
2161
  if method != route.method:
2044
2162
  continue
2045
2163
  match_results: Match | None = route.rule.match(path)
@@ -2051,8 +2169,7 @@ class ApiGatewayResolver(BaseRouter):
2051
2169
  route_keys = self._convert_matches_into_route_keys(match_results)
2052
2170
  return self._call_route(route, route_keys) # pass fn args
2053
2171
 
2054
- logger.debug(f"No match found for path {path} and method {method}")
2055
- return self._not_found(method)
2172
+ return self._handle_not_found(method=method, path=path)
2056
2173
 
2057
2174
  def _remove_prefix(self, path: str) -> str:
2058
2175
  """Remove the configured prefix from the path"""
@@ -2090,36 +2207,65 @@ class ApiGatewayResolver(BaseRouter):
2090
2207
 
2091
2208
  return path.startswith(prefix + "/")
2092
2209
 
2093
- def _not_found(self, method: str) -> ResponseBuilder:
2210
+ def _handle_not_found(self, method: str, path: str) -> ResponseBuilder:
2094
2211
  """Called when no matching route was found and includes support for the cors preflight response"""
2095
- headers = {}
2096
- if self._cors:
2097
- logger.debug("CORS is enabled, updating headers.")
2098
- extracted_origin_header = extract_origin_header(self.current_event.resolved_headers_field)
2099
- headers.update(self._cors.to_dict(extracted_origin_header))
2100
-
2101
- if method == "OPTIONS":
2102
- logger.debug("Pre-flight request detected. Returning CORS with null response")
2103
- headers["Access-Control-Allow-Methods"] = ",".join(sorted(self._cors_methods))
2104
- return ResponseBuilder(
2105
- response=Response(status_code=204, content_type=None, headers=headers, body=""),
2106
- serializer=self._serializer,
2107
- )
2212
+ logger.debug(f"No match found for path {path} and method {method}")
2108
2213
 
2109
- handler = self._lookup_exception_handler(NotFoundError)
2110
- if handler:
2111
- return self._response_builder_class(response=handler(NotFoundError()), serializer=self._serializer)
2214
+ def not_found_handler():
2215
+ """Route handler for 404s
2216
+
2217
+ It handles in the following order:
2218
+
2219
+ 1. Pre-flight CORS requests (OPTIONS)
2220
+ 2. Detects and calls custom HTTP 404 handler
2221
+ 3. Returns standard 404 along with CORS headers
2222
+
2223
+ Returns
2224
+ -------
2225
+ Response
2226
+ HTTP 404 response
2227
+ """
2228
+ _headers: dict[str, Any] = {}
2229
+
2230
+ # Pre-flight request? Return immediately to avoid browser error
2231
+ if self._cors and method == "OPTIONS":
2232
+ logger.debug("Pre-flight request detected. Returning CORS with empty response")
2233
+ _headers["Access-Control-Allow-Methods"] = CORSConfig.build_allow_methods(self._cors_methods)
2234
+
2235
+ return Response(status_code=204, content_type=None, headers=_headers, body="")
2236
+
2237
+ # Customer registered 404 route? Call it.
2238
+ custom_not_found_handler = self._lookup_exception_handler(NotFoundError)
2239
+ if custom_not_found_handler:
2240
+ return custom_not_found_handler(NotFoundError())
2112
2241
 
2113
- return self._response_builder_class(
2114
- response=Response(
2242
+ # No CORS and no custom 404 fn? Default response
2243
+ return Response(
2115
2244
  status_code=HTTPStatus.NOT_FOUND.value,
2116
2245
  content_type=content_types.APPLICATION_JSON,
2117
- headers=headers,
2246
+ headers=_headers,
2118
2247
  body={"statusCode": HTTPStatus.NOT_FOUND.value, "message": "Not found"},
2119
- ),
2120
- serializer=self._serializer,
2248
+ )
2249
+
2250
+ # We create a route to trigger entire request chain (middleware+exception handlers)
2251
+ route = Route(
2252
+ rule=self._compile_regex(r".*"),
2253
+ method=method,
2254
+ path=path,
2255
+ func=not_found_handler,
2256
+ cors=self._cors_enabled,
2257
+ compress=False,
2121
2258
  )
2122
2259
 
2260
+ # Add matched Route reference into the Resolver context
2261
+ self.append_context(_route=route, _path=path)
2262
+
2263
+ # Kick-off request chain:
2264
+ # -> exception_handlers()
2265
+ # --> middlewares()
2266
+ # ---> not_found_route()
2267
+ return self._call_route(route=route, route_arguments={})
2268
+
2123
2269
  def _call_route(self, route: Route, route_arguments: dict[str, str]) -> ResponseBuilder:
2124
2270
  """Actually call the matching route with any provided keyword arguments."""
2125
2271
  try:
@@ -2344,6 +2490,7 @@ class Router(BaseRouter):
2344
2490
  operation_id: str | None = None,
2345
2491
  include_in_schema: bool = True,
2346
2492
  security: list[dict[str, list[str]]] | None = None,
2493
+ openapi_extensions: dict[str, Any] | None = None,
2347
2494
  middlewares: list[Callable[..., Any]] | None = None,
2348
2495
  ):
2349
2496
  def register_route(func: Callable):
@@ -2351,6 +2498,8 @@ class Router(BaseRouter):
2351
2498
  methods = (method,) if isinstance(method, str) else tuple(method)
2352
2499
  frozen_responses = _FrozenDict(responses) if responses else None
2353
2500
  frozen_tags = frozenset(tags) if tags else None
2501
+ frozen_security = _FrozenListDict(security) if security else None
2502
+ fronzen_openapi_extensions = _FrozenDict(openapi_extensions) if openapi_extensions else None
2354
2503
 
2355
2504
  route_key = (
2356
2505
  rule,
@@ -2365,7 +2514,8 @@ class Router(BaseRouter):
2365
2514
  frozen_tags,
2366
2515
  operation_id,
2367
2516
  include_in_schema,
2368
- security,
2517
+ frozen_security,
2518
+ fronzen_openapi_extensions,
2369
2519
  )
2370
2520
 
2371
2521
  # Collate Middleware for routes
@@ -2446,6 +2596,7 @@ class APIGatewayRestResolver(ApiGatewayResolver):
2446
2596
  operation_id: str | None = None,
2447
2597
  include_in_schema: bool = True,
2448
2598
  security: list[dict[str, list[str]]] | None = None,
2599
+ openapi_extensions: dict[str, Any] | None = None,
2449
2600
  middlewares: list[Callable[..., Any]] | None = None,
2450
2601
  ):
2451
2602
  # NOTE: see #1552 for more context.
@@ -2463,6 +2614,7 @@ class APIGatewayRestResolver(ApiGatewayResolver):
2463
2614
  operation_id,
2464
2615
  include_in_schema,
2465
2616
  security,
2617
+ openapi_extensions,
2466
2618
  middlewares,
2467
2619
  )
2468
2620
 
@@ -2524,3 +2676,24 @@ class ALBResolver(ApiGatewayResolver):
2524
2676
  def _get_base_path(self) -> str:
2525
2677
  # ALB doesn't have a stage variable, so we just return an empty string
2526
2678
  return ""
2679
+
2680
+ @override
2681
+ def _to_response(self, result: dict | tuple | Response) -> Response:
2682
+ """Convert the route's result to a Response
2683
+
2684
+ ALB requires a non-null body otherwise it converts as HTTP 5xx
2685
+
2686
+ 3 main result types are supported:
2687
+
2688
+ - Dict[str, Any]: Rest api response with just the Dict to json stringify and content-type is set to
2689
+ application/json
2690
+ - Tuple[dict, int]: Same dict handling as above but with the option of including a status code
2691
+ - Response: returned as is, and allows for more flexibility
2692
+ """
2693
+
2694
+ # NOTE: Minor override for early return on Response with null body for ALB
2695
+ if isinstance(result, Response) and result.body is None:
2696
+ logger.debug("ALB doesn't allow None responses; converting to empty string")
2697
+ result.body = ""
2698
+
2699
+ return super()._to_response(result)