diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 72c760f126e..1e2a88b935c 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1867,6 +1867,7 @@ def get_openapi_schema( get_definitions, ) from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Tag + from aws_lambda_powertools.event_handler.openapi.schema_generator import _validate_openapi_extension_keys from aws_lambda_powertools.event_handler.openapi.types import ( COMPONENT_REF_TEMPLATE, ) @@ -1889,6 +1890,8 @@ def get_openapi_schema( if not isinstance(openapi_extensions, dict): openapi_extensions = {} + _validate_openapi_extension_keys(openapi_extensions) + output: dict[str, Any] = { "openapi": openapi_version, "info": info, diff --git a/aws_lambda_powertools/event_handler/middlewares/openapi_validation.py b/aws_lambda_powertools/event_handler/middlewares/openapi_validation.py index d0e76004243..e193234eec4 100644 --- a/aws_lambda_powertools/event_handler/middlewares/openapi_validation.py +++ b/aws_lambda_powertools/event_handler/middlewares/openapi_validation.py @@ -27,7 +27,7 @@ RequestValidationError, ResponseValidationError, ) -from aws_lambda_powertools.event_handler.openapi.params import Param, UploadFile +from aws_lambda_powertools.event_handler.openapi.params import Param, ParamTypes, UploadFile from aws_lambda_powertools.event_handler.openapi.types import UnionType from aws_lambda_powertools.utilities.data_classes.alb_event import ALBEvent from aws_lambda_powertools.utilities.data_classes.vpc_lattice import VPCLatticeEventV2 @@ -430,6 +430,11 @@ def _request_params_to_args( loc = (field_info.in_.value, field.alias) value = received_params.get(field.alias) + # Route args are keyed by the path-template placeholder (the Python argument + # name), so an explicit Path alias must not make the param unsatisfiable. + if value is None and field_info.in_ == ParamTypes.path and field.name in received_params: + value = received_params[field.name] + # If we don't have a value, see if it's required or has a default if value is None: _handle_missing_field_value(field, values, errors, loc) diff --git a/aws_lambda_powertools/event_handler/openapi/encoders.py b/aws_lambda_powertools/event_handler/openapi/encoders.py index 5d55280382c..53b7a950ffe 100644 --- a/aws_lambda_powertools/event_handler/openapi/encoders.py +++ b/aws_lambda_powertools/event_handler/openapi/encoders.py @@ -327,6 +327,11 @@ def decimal_encoder(dec_value: Decimal) -> int | float: >>> decimal_encoder(Decimal("1")) 1 """ + if not dec_value.is_finite(): + raise SerializationError( + f"Unable to serialize the Decimal value {dec_value} as it is not finite.", + "See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#serializing-objects", + ) if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] return int(dec_value) else: diff --git a/aws_lambda_powertools/event_handler/openapi/schema_generator.py b/aws_lambda_powertools/event_handler/openapi/schema_generator.py index 0d3de7e1f8c..2eb9b55a0b5 100644 --- a/aws_lambda_powertools/event_handler/openapi/schema_generator.py +++ b/aws_lambda_powertools/event_handler/openapi/schema_generator.py @@ -253,6 +253,8 @@ def _build_responses( dependant=dependant, model_name_map=model_name_map, field_mapping=field_mapping, + resp_code=resp_code, + status_code=status_code, ) else: response_schema = _build_return_schema( @@ -353,8 +355,13 @@ def _create_regular_parameter( field_mapping=field_mapping, ) + # Path parameter names must match the route-template placeholders, which are + # keyed by the Python argument name - an explicit alias would otherwise emit + # a name that does not exist in the path template. + param_name = param.name if field_info.in_.value == "path" else param.alias + parameter: dict[str, Any] = { - "name": param.alias, + "name": param_name, "in": field_info.in_.value, "required": param.required, "schema": param_schema, @@ -370,18 +377,36 @@ def _create_regular_parameter( return parameter -def _get_basic_type_schema(param_type: type) -> dict[str, str]: +def _get_basic_type_schema(param_type: type) -> dict[str, Any]: """Get basic OpenAPI schema for simple types.""" - type_map: dict[type, str] = {bool: "boolean", int: "integer", float: "number"} try: - for base_type, schema_type in type_map.items(): - if issubclass(param_type, base_type): - return {"type": schema_type} - return {"type": "string"} - except TypeError: + from pydantic import TypeAdapter + + return TypeAdapter(param_type).json_schema() + except Exception: return {"type": "string"} +def _validate_openapi_extension_keys(openapi_extensions: dict[str, Any]) -> None: + """Warn when an OpenAPI extension key does not respect the x- prefix convention. + + The OpenAPI spec reserves the `x-` prefix for extensions. We warn instead of raising so that + applications already serving a schema with a wrong key keep working; this becomes an error in v4. + """ + import warnings + + from aws_lambda_powertools.warnings import PowertoolsUserWarning + + for extension_key in openapi_extensions: + if not str(extension_key).startswith("x-"): + warnings.warn( + f"OpenAPI extension key `{extension_key}` must start with `x-`; it is still emitted, " + "but this will become an error in a future major version.", + category=PowertoolsUserWarning, + stacklevel=2, + ) + + def _apply_optional_fields( operation: dict[str, Any], *, @@ -392,6 +417,7 @@ def _apply_optional_fields( if security: operation["security"] = security if openapi_extensions: + _validate_openapi_extension_keys(openapi_extensions) operation.update(openapi_extensions) @@ -501,16 +527,21 @@ def _build_custom_response( dependant: Dependant, model_name_map: dict[TypeModelOrEnum, str], field_mapping: dict[tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue], + resp_code: int, + status_code: int = DEFAULT_STATUS_CODE, ) -> OpenAPIResponse: """Build a single custom response, resolving model references in content.""" if "content" not in response: - response["content"] = { - DEFAULT_CONTENT_TYPE: _build_return_schema( - param=dependant.return_param, - model_name_map=model_name_map, - field_mapping=field_mapping, - ), - } + # Only the route's own status code inherits the return-type schema; + # description-only entries (e.g. a 404) stay description-only. + if resp_code == status_code: + response["content"] = { + DEFAULT_CONTENT_TYPE: _build_return_schema( + param=dependant.return_param, + model_name_map=model_name_map, + field_mapping=field_mapping, + ), + } return response for content_type, payload in response["content"].items(): diff --git a/aws_lambda_powertools/event_handler/openapi/types.py b/aws_lambda_powertools/event_handler/openapi/types.py index 52915538b3e..de4b74b38c5 100644 --- a/aws_lambda_powertools/event_handler/openapi/types.py +++ b/aws_lambda_powertools/event_handler/openapi/types.py @@ -35,7 +35,7 @@ # For security reasons, we hide **msg** details (don't leak Python, Pydantic or filenames) "type": {"title": "Error Type", "type": "string"}, }, - "required": ["loc", "msg", "type"], + "required": ["loc", "type"], } validation_error_response_definition = { diff --git a/tests/functional/event_handler/_pydantic/test_openapi_encoders.py b/tests/functional/event_handler/_pydantic/test_openapi_encoders.py index beac1764064..a6f945b8463 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_encoders.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_encoders.py @@ -242,3 +242,14 @@ def serializer(value): # THEN we should get the custom serializer output assert result == {"kind": "serialized"} + + +@pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"]) +def test_openapi_encode_non_finite_decimal_raises_serialization_error(value): + from decimal import Decimal + + # GIVEN a Decimal that has no JSON number representation + # WHEN we try to encode it + # THEN we raise a SerializationError instead of leaking a TypeError + with pytest.raises(SerializationError, match="not finite"): + jsonable_encoder(Decimal(value)) diff --git a/tests/functional/event_handler/_pydantic/test_openapi_extensions.py b/tests/functional/event_handler/_pydantic/test_openapi_extensions.py index e7b64cba38b..c7f5937bdbd 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_extensions.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_extensions.py @@ -2,6 +2,8 @@ import json +import pytest + from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Router from aws_lambda_powertools.event_handler.openapi.models import ( APIKey, @@ -11,6 +13,7 @@ OAuthFlows, Server, ) +from aws_lambda_powertools.warnings import PowertoolsUserWarning def test_openapi_extension_root_level(): @@ -266,3 +269,31 @@ def lambda_handler_post(): assert ( schema["paths"]["/test"]["post"]["x-amazon-apigateway-request-validator"] == openapi_extension_validator_detail ) + + +def test_openapi_extension_root_level_warns_without_x_prefix(): + # GIVEN an APIGatewayRestResolver instance + app = APIGatewayRestResolver() + + # WHEN we get the OpenAPI schema with a root extension that does not start with x- + # THEN we warn and still emit the schema, so existing applications keep working + with pytest.warns(PowertoolsUserWarning, match="must start with `x-`"): + schema = json.loads(app.get_openapi_json_schema(openapi_extensions={"amazon-apigateway-cors": {"maxAge": 0}})) + + assert schema["amazon-apigateway-cors"] == {"maxAge": 0} + + +def test_openapi_extension_operation_level_warns_without_x_prefix(): + # GIVEN a route with an operation extension that does not start with x- + app = APIGatewayRestResolver() + + @app.get("/test", openapi_extensions={"amazon-apigateway-integration": {"type": "aws_proxy"}}) + def lambda_handler(): + pass + + # WHEN we get the OpenAPI schema + # THEN we warn and the extension is still emitted on the operation + with pytest.warns(PowertoolsUserWarning, match="must start with `x-`"): + schema = json.loads(app.get_openapi_json_schema()) + + assert schema["paths"]["/test"]["get"]["amazon-apigateway-integration"] == {"type": "aws_proxy"} diff --git a/tests/functional/event_handler/_pydantic/test_openapi_params.py b/tests/functional/event_handler/_pydantic/test_openapi_params.py index af33ef9daf6..5e9bf28d5a3 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_params.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_params.py @@ -1,10 +1,11 @@ import json from dataclasses import dataclass from datetime import datetime +from enum import Enum from typing import Annotated, Literal import pytest -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response, Router from aws_lambda_powertools.event_handler.openapi.models import ( @@ -71,7 +72,10 @@ def search_handler(params: Annotated[QueryParams, Query()]): elif param.name == "search": assert param.required is False # Optional field assert param.description == "Search term" - assert param.schema_.type == "string" + # str | None is rendered as an anyOf, not a bare string + assert param.schema_.type is None + assert param.schema_.anyOf is not None + assert [s.type for s in param.schema_.anyOf] == ["string", "null"] def test_openapi_pydantic_header_params(): @@ -119,7 +123,10 @@ def protected_handler(headers: Annotated[HeaderParams, Header()]): elif param.name == "accept-language": assert param.required is False # Optional field assert param.description == "Language preference" - assert param.schema_.type == "string" + # str | None is rendered as an anyOf, not a bare string + assert param.schema_.type is None + assert param.schema_.anyOf is not None + assert [s.type for s in param.schema_.anyOf] == ["string", "null"] def test_openapi_pydantic_mixed_params(): @@ -1668,3 +1675,71 @@ def handler(x_custom: Annotated[str, Header]) -> dict: assert result["statusCode"] == 200 response_body = json.loads(result["body"]) assert response_body["header"] == "my-value" + + +def test_openapi_query_model_fields_keep_their_types(): + # GIVEN a Pydantic model used as Query() with optional, sequence and Enum fields + class Color(Enum): + red = "red" + blue = "blue" + + class Filters(BaseModel): + limit: int | None = None + tags: list[str] = [] + color: Color = Color.red + + app = APIGatewayRestResolver(enable_validation=True) + + @app.get("/search") + def search(filters: Annotated[Filters, Query()]): + return filters.model_dump() + + # WHEN we get the OpenAPI schema + schema = json.loads(app.get_openapi_json_schema()) + parameters = {p["name"]: p["schema"] for p in schema["paths"]["/search"]["get"]["parameters"]} + + # THEN each field keeps its own type instead of collapsing to string + assert parameters["limit"] == {"anyOf": [{"type": "integer"}, {"type": "null"}]} + assert parameters["tags"] == {"type": "array", "items": {"type": "string"}} + assert parameters["color"]["enum"] == ["red", "blue"] + assert parameters["color"]["type"] == "string" + + +def test_openapi_query_model_arbitrary_type_falls_back_to_string(): + # GIVEN a Pydantic model used as Query() with a field whose type has no JSON schema + class Opaque: + pass + + class Filters(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + token: Opaque + + app = APIGatewayRestResolver(enable_validation=True) + + @app.get("/search") + def search(filters: Annotated[Filters, Query()]): + return {} + + # WHEN we get the OpenAPI schema + schema = json.loads(app.get_openapi_json_schema()) + parameters = {p["name"]: p["schema"] for p in schema["paths"]["/search"]["get"]["parameters"]} + + # THEN the field is documented as a string rather than failing schema generation + assert parameters["token"] == {"type": "string"} + + +def test_openapi_validation_error_schema_does_not_require_hidden_msg(): + # GIVEN a resolver with validation enabled + app = APIGatewayRestResolver(enable_validation=True) + + @app.get("/") + def handler(): + return {} + + # WHEN we get the OpenAPI schema + schema = json.loads(app.get_openapi_json_schema()) + + # THEN the ValidationError component only requires the fields it actually exposes + validation_error = schema["components"]["schemas"]["ValidationError"] + assert validation_error["required"] == ["loc", "type"] + assert "msg" not in validation_error["properties"] diff --git a/tests/functional/event_handler/_pydantic/test_openapi_responses.py b/tests/functional/event_handler/_pydantic/test_openapi_responses.py index b7af4168b3e..23c63be2eea 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_responses.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_responses.py @@ -1,3 +1,4 @@ +import json from secrets import randbelow from pydantic import BaseModel @@ -492,3 +493,32 @@ def items_by_name() -> dict[str, Item]: dict_schema = schema.paths["/items/by-name"].get.responses[200].content["application/json"].schema_ assert dict_schema.type == "object" assert dict_schema.additionalProperties.ref == "#/components/schemas/Item" + + +def test_openapi_custom_response_without_content_keeps_description_only(): + # GIVEN a route with a return type and a description-only custom response for another status code + class Todo(BaseModel): + id: int + + app = APIGatewayRestResolver(enable_validation=True) + + @app.get( + "/todos/", + responses={ + 200: {"description": "The todo"}, + 404: {"description": "Todo not found"}, + 201: {"description": "Created"}, + }, + ) + def get_todo(todo_id: int) -> Todo: + return Todo(id=todo_id) + + # WHEN we get the OpenAPI schema + schema = json.loads(app.get_openapi_json_schema()) + responses = schema["paths"]["/todos/{todo_id}"]["get"]["responses"] + + # THEN only the route's own status code carries the return-type content + assert responses["200"]["description"] == "The todo" + assert "content" in responses["200"] + assert responses["404"] == {"description": "Todo not found"} + assert responses["201"] == {"description": "Created"} diff --git a/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py b/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py index 0fb965d23d4..6b76d1d3585 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py @@ -4591,6 +4591,26 @@ def handler() -> Response[None]: assert result["body"] == "" +def test_validate_path_param_with_alias(gw_event): + # GIVEN a path parameter declared with an explicit alias + app = APIGatewayRestResolver(enable_validation=True) + gw_event["path"] = "/users/42" + gw_event["httpMethod"] = "GET" + + @app.get("/users/") + def get_user(user_id: Annotated[int, Path(alias="userId")]): + return {"user_id": user_id} + + # WHEN the route is resolved + result = app(gw_event, {}) + + # THEN the value is found by the path template name and the schema names the template placeholder + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"user_id": 42} + parameters = app.get_openapi_schema().paths["/users/{user_id}"].get.parameters + assert [p.name for p in parameters] == ["user_id"] + + def test_header_sequence_single_value_across_resolvers(gw_event_http): # GIVEN an APIGatewayHttpResolver with validation enabled and a single-value sequence header app = APIGatewayHttpResolver(enable_validation=True)