Skip to content

Commit 810aadc

Browse files
feat(event_handler): enhance OpenAPI response with headers, links, examples and encoding (#7312)
* feat(event_handler): enhance OpenAPI response with headers, links, examples and encoding - Add OpenAPIResponseHeader TypedDict with full OpenAPI spec compliance - Add headers and links fields to OpenAPIResponse TypedDict - Add examples and encoding fields to content models - Fix processing logic to preserve examples when using model field - Maintain 100% backward compatibility with total=False - Add comprehensive functional tests covering all scenarios Fixes #4870 * fix: apply ruff formatting to fix import sorting - Fixed import order in tests/unit/test_shared_functions.py - Addresses linting issues identified in PR review * fix: remove unused imports in test file - Removed unused pytest import - Removed unused Response import - Resolves remaining linting errors from make pr * fix: apply final formatting changes - Minor whitespace adjustments from ruff formatter - Ensures consistent code formatting * fix: resolve mypy TypedDict errors in OpenAPI response handling - Fixed type handling for OpenAPIResponseContentSchema and OpenAPIResponseContentModel - Removed variable redefinition in _get_openapi_path method - Improved type safety for model field processing - Addresses remaining type checking issues from make pr * fix: improve code quality for SonarCloud compliance - Reverted to more maintainable loop-based approach for field copying - Ensures type safety while addressing SonarCloud code quality concerns - Maintains functionality for preserving examples when using model field - Addresses SonarCloud finding identified by dreamorosi * refactor(tests): consolidate OpenAPI response tests as requested by @leandrodamascena - Moved all tests from test_openapi_response_fields.py to existing test_openapi_responses.py - Removed duplicate test file to improve code organization - All 21 tests pass (12 original + 9 consolidated) - Addresses reviewer feedback for better test file structure * refactor(tests): remove duplicate/unnecessary tests as requested by @leandrodamascena - Removed test_openapi_response_encoding_preserved_with_model (duplicate of examples test) - Removed test_openapi_response_all_fields_together (unnecessary) - Removed test_openapi_response_backward_compatibility (unnecessary) - Removed test_openapi_response_empty_optional_fields (unnecessary) - Removed test_openapi_response_multiple_content_types_with_fields (unnecessary) - Removed test_openapi_response_with_router (already covered elsewhere) - Cleaned up unused Router import - Kept only essential tests: headers, links, and examples preservation - All 15 remaining tests pass successfully --------- Co-authored-by: Leandro Damascena <[email protected]>
1 parent 15b54c4 commit 810aadc

File tree

3 files changed

+166
-9
lines changed

3 files changed

+166
-9
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -661,30 +661,36 @@ def _get_openapi_path( # noqa PLR0912
661661
else:
662662
# Need to iterate to transform any 'model' into a 'schema'
663663
for content_type, payload in response["content"].items():
664-
new_payload: OpenAPIResponseContentSchema
665-
666664
# Case 2.1: the 'content' has a model
667665
if "model" in payload:
668666
# Find the model in the dependant's extra models
667+
model_payload_typed = cast(OpenAPIResponseContentModel, payload)
669668
return_field = next(
670669
filter(
671-
lambda model: model.type_ is cast(OpenAPIResponseContentModel, payload)["model"],
670+
lambda model: model.type_ is model_payload_typed["model"],
672671
self.dependant.response_extra_models,
673672
),
674673
)
675674
if not return_field:
676675
raise AssertionError("Model declared in custom responses was not found")
677676

678-
new_payload = self._openapi_operation_return(
677+
model_payload = self._openapi_operation_return(
679678
param=return_field,
680679
model_name_map=model_name_map,
681680
field_mapping=field_mapping,
682681
)
683682

683+
# Preserve existing fields like examples, encoding, etc.
684+
new_payload: OpenAPIResponseContentSchema = {}
685+
for key, value in payload.items():
686+
if key != "model":
687+
new_payload[key] = value # type: ignore[literal-required]
688+
new_payload.update(model_payload) # Add/override with model schema
689+
684690
# Case 2.2: the 'content' has a schema
685691
else:
686692
# Do nothing! We already have what we need!
687-
new_payload = payload
693+
new_payload = cast(OpenAPIResponseContentSchema, payload)
688694

689695
response["content"][content_type] = new_payload
690696

aws_lambda_powertools/event_handler/openapi/types.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,32 @@
6363
}
6464

6565

66+
class OpenAPIResponseHeader(TypedDict, total=False):
67+
"""OpenAPI Response Header Object"""
68+
69+
description: NotRequired[str]
70+
schema: NotRequired[dict[str, Any]]
71+
examples: NotRequired[dict[str, Any]]
72+
style: NotRequired[str]
73+
explode: NotRequired[bool]
74+
allowReserved: NotRequired[bool]
75+
deprecated: NotRequired[bool]
76+
77+
6678
class OpenAPIResponseContentSchema(TypedDict, total=False):
6779
schema: dict
80+
examples: NotRequired[dict[str, Any]]
81+
encoding: NotRequired[dict[str, Any]]
6882

6983

70-
class OpenAPIResponseContentModel(TypedDict):
84+
class OpenAPIResponseContentModel(TypedDict, total=False):
7185
model: Any
86+
examples: NotRequired[dict[str, Any]]
87+
encoding: NotRequired[dict[str, Any]]
7288

7389

74-
class OpenAPIResponse(TypedDict):
75-
description: str
90+
class OpenAPIResponse(TypedDict, total=False):
91+
description: str # Still required
92+
headers: NotRequired[dict[str, OpenAPIResponseHeader]]
7693
content: NotRequired[dict[str, OpenAPIResponseContentSchema | OpenAPIResponseContentModel]]
94+
links: NotRequired[dict[str, Any]]

tests/functional/event_handler/_pydantic/test_openapi_responses.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from secrets import randbelow
2-
from typing import Union
2+
from typing import Optional, Union
33

44
from pydantic import BaseModel
55

@@ -237,3 +237,136 @@ def handler():
237237
assert 200 in responses.keys()
238238
assert responses[200].description == "Successful Response"
239239
assert 422 not in responses.keys()
240+
241+
242+
def test_openapi_response_with_headers():
243+
"""Test that response headers are properly included in OpenAPI schema"""
244+
app = APIGatewayRestResolver(enable_validation=True)
245+
246+
@app.get(
247+
"/",
248+
responses={
249+
200: {
250+
"description": "Successful Response",
251+
"headers": {
252+
"X-Rate-Limit": {
253+
"description": "Rate limit header",
254+
"schema": {"type": "integer"},
255+
},
256+
"X-Custom-Header": {
257+
"description": "Custom header",
258+
"schema": {"type": "string"},
259+
"examples": {"example1": "value1"},
260+
},
261+
},
262+
},
263+
},
264+
)
265+
def handler():
266+
return {"message": "hello"}
267+
268+
schema = app.get_openapi_schema()
269+
response_dict = schema.paths["/"].get.responses[200]
270+
271+
# Verify headers are present
272+
assert "headers" in response_dict
273+
headers = response_dict["headers"]
274+
275+
# Check X-Rate-Limit header
276+
assert "X-Rate-Limit" in headers
277+
assert headers["X-Rate-Limit"]["description"] == "Rate limit header"
278+
assert headers["X-Rate-Limit"]["schema"]["type"] == "integer"
279+
280+
# Check X-Custom-Header with examples
281+
assert "X-Custom-Header" in headers
282+
assert headers["X-Custom-Header"]["description"] == "Custom header"
283+
assert headers["X-Custom-Header"]["schema"]["type"] == "string"
284+
assert headers["X-Custom-Header"]["examples"]["example1"] == "value1"
285+
286+
287+
def test_openapi_response_with_links():
288+
"""Test that response links are properly included in OpenAPI schema"""
289+
app = APIGatewayRestResolver(enable_validation=True)
290+
291+
@app.get(
292+
"/users/{user_id}",
293+
responses={
294+
200: {
295+
"description": "User details",
296+
"links": {
297+
"GetUserOrders": {
298+
"operationId": "getUserOrders",
299+
"parameters": {"userId": "$response.body#/id"},
300+
"description": "Get orders for this user",
301+
},
302+
},
303+
},
304+
},
305+
)
306+
def get_user(user_id: str):
307+
return {"id": user_id, "name": "John Doe"}
308+
309+
schema = app.get_openapi_schema()
310+
response = schema.paths["/users/{user_id}"].get.responses[200]
311+
312+
# Verify links are present
313+
links = response.links
314+
315+
assert "GetUserOrders" in links
316+
assert links["GetUserOrders"].operationId == "getUserOrders"
317+
assert links["GetUserOrders"].parameters["userId"] == "$response.body#/id"
318+
assert links["GetUserOrders"].description == "Get orders for this user"
319+
320+
321+
def test_openapi_response_examples_preserved_with_model():
322+
"""Test that examples are preserved when using model in response content"""
323+
app = APIGatewayRestResolver(enable_validation=True)
324+
325+
class UserResponse(BaseModel):
326+
id: int
327+
name: str
328+
email: Optional[str] = None
329+
330+
@app.get(
331+
"/",
332+
responses={
333+
200: {
334+
"description": "User response",
335+
"content": {
336+
"application/json": {
337+
"model": UserResponse,
338+
"examples": {
339+
"example1": {
340+
"summary": "Example 1",
341+
"value": {"id": 1, "name": "John", "email": "[email protected]"},
342+
},
343+
"example2": {
344+
"summary": "Example 2",
345+
"value": {"id": 2, "name": "Jane"},
346+
},
347+
},
348+
},
349+
},
350+
},
351+
},
352+
)
353+
def handler() -> UserResponse:
354+
return UserResponse(id=1, name="Test")
355+
356+
schema = app.get_openapi_schema()
357+
content = schema.paths["/"].get.responses[200].content["application/json"]
358+
359+
# Verify model schema is present
360+
assert content.schema_.ref == "#/components/schemas/UserResponse"
361+
362+
# Verify examples are preserved
363+
examples = content.examples
364+
365+
assert "example1" in examples
366+
assert examples["example1"].summary == "Example 1"
367+
assert examples["example1"].value["id"] == 1
368+
assert examples["example1"].value["name"] == "John"
369+
370+
assert "example2" in examples
371+
assert examples["example2"].summary == "Example 2"
372+
assert examples["example2"].value["id"] == 2

0 commit comments

Comments
 (0)