Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1841,6 +1841,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,
)
Expand All @@ -1863,6 +1864,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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.common import CaseInsensitiveDict
Expand Down Expand Up @@ -422,6 +422,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)
Expand Down
5 changes: 5 additions & 0 deletions aws_lambda_powertools/event_handler/openapi/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 35 additions & 15 deletions aws_lambda_powertools/event_handler/openapi/schema_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -370,18 +377,25 @@ 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:
"""Ensure every OpenAPI extension key respects the x- prefix convention."""
from aws_lambda_powertools.event_handler.openapi.exceptions import SchemaValidationError

for extension_key in openapi_extensions:
if not str(extension_key).startswith("x-"):
raise SchemaValidationError("An OpenAPI extension key must start with x-")


def _apply_optional_fields(
operation: dict[str, Any],
*,
Expand All @@ -392,6 +406,7 @@ def _apply_optional_fields(
if security:
operation["security"] = security
if openapi_extensions:
_validate_openapi_extension_keys(openapi_extensions)
operation.update(openapi_extensions)


Expand Down Expand Up @@ -501,16 +516,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():
Expand Down
2 changes: 1 addition & 1 deletion aws_lambda_powertools/event_handler/openapi/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,14 @@

# 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"):

Check warning on line 254 in tests/functional/event_handler/_pydantic/test_openapi_encoders.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=aws-powertools_powertools-lambda-python&issues=AaF0Q_jYvCiqu-XFEwJU&open=AaF0Q_jYvCiqu-XFEwJU&pullRequest=8630
jsonable_encoder(Decimal(value))
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import json

import pytest

from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Router
from aws_lambda_powertools.event_handler.openapi.exceptions import SchemaValidationError
from aws_lambda_powertools.event_handler.openapi.models import (
APIKey,
APIKeyIn,
Expand Down Expand Up @@ -266,3 +269,27 @@ def lambda_handler_post():
assert (
schema["paths"]["/test"]["post"]["x-amazon-apigateway-request-validator"] == openapi_extension_validator_detail
)


def test_openapi_extension_root_level_requires_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 it must be rejected like Server and SecurityScheme extensions already are
with pytest.raises(SchemaValidationError, match="must start with x-"):
app.get_openapi_json_schema(openapi_extensions={"amazon-apigateway-cors": {"maxAge": 0}})


def test_openapi_extension_operation_level_requires_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 it must be rejected
with pytest.raises(SchemaValidationError, match="must start with x-"):
app.get_openapi_json_schema()
81 changes: 78 additions & 3 deletions tests/functional/event_handler/_pydantic/test_openapi_params.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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"]
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from secrets import randbelow

from pydantic import BaseModel
Expand Down Expand Up @@ -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/<todo_id>",
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"}
Original file line number Diff line number Diff line change
Expand Up @@ -4390,3 +4390,23 @@ def handler() -> Response[None]:
result = app(gw_event_alb, {})
assert result["statusCode"] == 204
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/<user_id>")
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"]