Skip to content

Commit 7c49d72

Browse files
Improve error message and fix header serialization
1 parent 203941c commit 7c49d72

File tree

3 files changed

+115
-3
lines changed

3 files changed

+115
-3
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,8 +837,14 @@ def _openapi_operation_parameters(
837837

838838
for field_name, field_def in model_class.model_fields.items():
839839
# Create individual parameter for each model field
840+
param_name = field_def.alias or field_name
841+
842+
# Convert snake_case to kebab-case for headers (HTTP convention)
843+
if isinstance(field_info, Header):
844+
param_name = param_name.replace("_", "-")
845+
840846
individual_param = {
841-
"name": field_def.alias or field_name,
847+
"name": param_name,
842848
"in": field_info.in_.value,
843849
"required": field_def.is_required()
844850
if hasattr(field_def, "is_required")

aws_lambda_powertools/event_handler/middlewares/openapi_validation.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping, Sequence
88
from urllib.parse import parse_qs
99

10-
from pydantic import BaseModel
10+
from pydantic import BaseModel, ValidationError
1111

1212
from aws_lambda_powertools.event_handler.middlewares import BaseMiddlewareHandler
1313
from aws_lambda_powertools.event_handler.openapi.compat import (
@@ -338,6 +338,11 @@ def _request_params_to_args_with_pydantic_support(
338338
# Extract individual fields from the request
339339
for model_field_name, model_field_def in model_class.model_fields.items():
340340
field_alias = model_field_def.alias or model_field_name
341+
342+
# Convert snake_case to kebab-case for headers (HTTP convention)
343+
if isinstance(field_info, Header):
344+
field_alias = field_alias.replace("_", "-")
345+
341346
field_value = received_params.get(field_alias)
342347

343348
if field_value is not None:
@@ -358,8 +363,21 @@ def _request_params_to_args_with_pydantic_support(
358363
try:
359364
model_instance = model_class(**model_data)
360365
values[field.name] = model_instance
366+
except ValidationError as e:
367+
# Extract detailed validation errors from Pydantic
368+
for error in e.errors():
369+
# Update the location to include the parameter source (query/header) and field path
370+
error_loc = [field_info.in_.value] + list(error["loc"])
371+
errors.append(
372+
{
373+
"type": error["type"],
374+
"loc": error_loc,
375+
"msg": error["msg"],
376+
"input": error.get("input"),
377+
},
378+
)
361379
except Exception as e:
362-
# Validation error
380+
# Fallback for non-Pydantic validation errors
363381
loc = (field_info.in_.value, field.alias)
364382
errors.append(
365383
{
@@ -593,6 +611,10 @@ def _normalize_multi_header_values_with_param(headers: MutableMapping[str, Any],
593611
# Normalize individual fields of the Pydantic model
594612
for field_name, field_def in model_class.model_fields.items():
595613
field_alias = field_def.alias or field_name
614+
615+
# Convert snake_case to kebab-case for headers (HTTP convention)
616+
field_alias = field_alias.replace("_", "-")
617+
596618
try:
597619
if len(headers[field_alias]) == 1:
598620
headers[field_alias] = headers[field_alias][0]

tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,47 @@ def search_handler(params: Annotated[QueryParams, Query()]):
9494
assert any("limit" in str(error) for error in body["detail"])
9595

9696

97+
def test_validate_pydantic_query_params_detailed_errors(gw_event):
98+
"""Test that Pydantic validation errors include detailed field-level information"""
99+
app = APIGatewayRestResolver(enable_validation=True)
100+
101+
class QueryParams(BaseModel):
102+
full_name: str = Field(..., min_length=5, description="Full name with minimum 5 characters")
103+
age: int = Field(..., ge=18, le=100, description="Age between 18 and 100")
104+
105+
@app.get("/query-model")
106+
def query_model(params: Annotated[QueryParams, Query()]):
107+
return {"full_name": params.full_name, "age": params.age}
108+
109+
# Test validation error with detailed field information
110+
gw_event["path"] = "/query-model"
111+
gw_event["queryStringParameters"] = {"full_name": "Jo", "age": "15"} # Both invalid
112+
113+
result = app(gw_event, {})
114+
assert result["statusCode"] == 422
115+
116+
body = json.loads(result["body"])
117+
assert "detail" in body
118+
119+
# Check that we get detailed field-level errors
120+
errors = body["detail"]
121+
122+
# Should have errors for both fields
123+
full_name_error = next((e for e in errors if "full_name" in e["loc"]), None)
124+
age_error = next((e for e in errors if "age" in e["loc"]), None)
125+
126+
assert full_name_error is not None, "Should have error for full_name field"
127+
assert age_error is not None, "Should have error for age field"
128+
129+
# Check error details for full_name
130+
assert full_name_error["loc"] == ["query", "full_name"]
131+
assert full_name_error["type"] == "string_too_short"
132+
133+
# Check error details for age
134+
assert age_error["loc"] == ["query", "age"]
135+
assert age_error["type"] == "greater_than_equal"
136+
137+
97138
def test_validate_pydantic_header_params(gw_event):
98139
"""Test that Pydantic models in Header parameters are validated correctly"""
99140
app = APIGatewayRestResolver(enable_validation=True)
@@ -141,6 +182,49 @@ def protected_handler(headers: Annotated[HeaderParams, Header()]):
141182
assert any("authorization" in str(error) for error in body["detail"])
142183

143184

185+
def test_validate_pydantic_header_snake_case_to_kebab_case_schema(gw_event):
186+
"""Test that snake_case header fields are converted to kebab-case in OpenAPI schema and validation"""
187+
app = APIGatewayRestResolver(enable_validation=True)
188+
app.enable_swagger()
189+
190+
class HeaderParams(BaseModel):
191+
correlation_id: str = Field(description="Correlation ID header")
192+
user_agent: str = Field(default="PowerTools/1.0", description="User agent header")
193+
194+
@app.get("/kebab-headers")
195+
def kebab_handler(headers: Annotated[HeaderParams, Header()]):
196+
return {
197+
"correlation_id": headers.correlation_id,
198+
"user_agent": headers.user_agent,
199+
}
200+
201+
# Test that OpenAPI schema uses kebab-case for headers
202+
openapi_schema = app.get_openapi_schema()
203+
operation = openapi_schema["paths"]["/kebab-headers"]["get"]
204+
parameters = operation["parameters"]
205+
206+
# Find the correlation_id parameter
207+
correlation_param = next((p for p in parameters if p["name"] == "correlation-id"), None)
208+
assert correlation_param is not None, "Should have correlation-id parameter in kebab-case"
209+
assert correlation_param["in"] == "header"
210+
211+
# Find the user_agent parameter
212+
user_agent_param = next((p for p in parameters if p["name"] == "user-agent"), None)
213+
assert user_agent_param is not None, "Should have user-agent parameter in kebab-case"
214+
assert user_agent_param["in"] == "header"
215+
216+
# Test validation with kebab-case headers
217+
gw_event["path"] = "/kebab-headers"
218+
gw_event["headers"] = {"correlation-id": "test-123", "user-agent": "TestClient/1.0"}
219+
220+
result = app(gw_event, {})
221+
assert result["statusCode"] == 200
222+
223+
body = json.loads(result["body"])
224+
assert body["correlation_id"] == "test-123"
225+
assert body["user_agent"] == "TestClient/1.0"
226+
227+
144228
def test_validate_pydantic_mixed_params(gw_event):
145229
"""Test that mixed Pydantic models (Query + Header) are validated correctly"""
146230
app = APIGatewayRestResolver(enable_validation=True)

0 commit comments

Comments
 (0)