Skip to content

Commit 579662e

Browse files
rubenfonsecaleandrodamascenaCavalcante Damascena
authored
fix(event_handler): OpenAPI schema version respects Pydantic version (#3860)
* fix(event_handler): OpenAPI schema version respects Pydantic version * fix: types * chore: add docs on pydantic version * chore: moved loading of pydantic to improve performance * chore: improve performance again * chore: update Swagger-UI to 5.x * chore: add links to pydantic openapi schemas * chore: change default OpenAPI version * Adding nofollow for external links --------- Co-authored-by: Leandro Damascena <[email protected]> Co-authored-by: Cavalcante Damascena <[email protected]>
1 parent 57fc5d5 commit 579662e

File tree

10 files changed

+315
-41
lines changed

10 files changed

+315
-41
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

+15
Original file line numberDiff line numberDiff line change
@@ -1455,10 +1455,25 @@ def get_openapi_schema(
14551455
get_definitions,
14561456
)
14571457
from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Server, Tag
1458+
from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2
14581459
from aws_lambda_powertools.event_handler.openapi.types import (
14591460
COMPONENT_REF_TEMPLATE,
14601461
)
14611462

1463+
# Pydantic V2 has no support for OpenAPI schema 3.0
1464+
if PYDANTIC_V2 and not openapi_version.startswith("3.1"):
1465+
warnings.warn(
1466+
"You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1",
1467+
stacklevel=2,
1468+
)
1469+
openapi_version = "3.1.0"
1470+
elif not PYDANTIC_V2 and not openapi_version.startswith("3.0"):
1471+
warnings.warn(
1472+
"You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0",
1473+
stacklevel=2,
1474+
)
1475+
openapi_version = "3.0.3"
1476+
14621477
# Start with the bare minimum required for a valid OpenAPI schema
14631478
info: Dict[str, Any] = {"title": title, "version": version}
14641479

Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
DEFAULT_API_VERSION = "1.0.0"
2-
DEFAULT_OPENAPI_VERSION = "3.0.0"
2+
DEFAULT_OPENAPI_VERSION = "3.0.3"

aws_lambda_powertools/event_handler/openapi/models.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,20 @@ class Config:
4545
# https://swagger.io/specification/#info-object
4646
class Info(BaseModel):
4747
title: str
48-
summary: Optional[str] = None
4948
description: Optional[str] = None
5049
termsOfService: Optional[str] = None
5150
contact: Optional[Contact] = None
5251
license: Optional[License] = None # noqa: A003
5352
version: str
5453

5554
if PYDANTIC_V2:
56-
model_config = {"extra": "allow"}
55+
summary: Optional[str] = None
56+
model_config = {"extra": "ignore"}
5757

5858
else:
5959

6060
class Config:
61-
extra = "allow"
61+
extra = "ignore"
6262

6363

6464
# https://swagger.io/specification/#server-variable-object

aws_lambda_powertools/event_handler/openapi/swagger_ui/swagger-ui-bundle.min.js

+12-8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aws_lambda_powertools/event_handler/openapi/swagger_ui/swagger-ui.min.css

+1-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/core/event_handler/api_gateway.md

+16-13
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,10 @@ This will enable full tracebacks errors in the response, print request and respo
959959

960960
### OpenAPI
961961

962-
When you enable [Data Validation](#data-validation), we use a combination of Pydantic Models and [OpenAPI](https://www.openapis.org/){target="_blank"} type annotations to add constraints to your API's parameters.
962+
When you enable [Data Validation](#data-validation), we use a combination of Pydantic Models and [OpenAPI](https://www.openapis.org/){target="_blank" rel="nofollow"} type annotations to add constraints to your API's parameters.
963+
964+
???+ warning "OpenAPI schema version depends on the installed version of Pydantic"
965+
Pydantic v1 generates [valid OpenAPI 3.0.3 schemas](https://docs.pydantic.dev/1.10/usage/schema/){target="_blank" rel="nofollow"}, and Pydantic v2 generates [valid OpenAPI 3.1.0 schemas](https://docs.pydantic.dev/latest/why/#json-schema){target="_blank" rel="nofollow"}.
963966

964967
In OpenAPI documentation tools like [SwaggerUI](#enabling-swaggerui), these annotations become readable descriptions, offering a self-explanatory API interface. This reduces boilerplate code while improving functionality and enabling auto-documentation.
965968

@@ -1042,18 +1045,18 @@ Below is an example configuration for serving Swagger UI from a custom path or C
10421045

10431046
Defining and customizing OpenAPI metadata gives detailed, top-level information about your API. Here's the method to set and tailor this metadata:
10441047

1045-
| Field Name | Type | Description |
1046-
| ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1047-
| `title` | `str` | The title for your API. It should be a concise, specific name that can be used to identify the API in documentation or listings. |
1048-
| `version` | `str` | The version of the API you are documenting. This could reflect the release iteration of the API and helps clients understand the evolution of the API. |
1049-
| `openapi_version` | `str` | Specifies the version of the OpenAPI Specification on which your API is based. For most contemporary APIs, the default value would be `3.0.0` or higher. |
1050-
| `summary` | `str` | A short and informative summary that can provide an overview of what the API does. This can be the same as or different from the title but should add context or information. |
1051-
| `description` | `str` | A verbose description that can include Markdown formatting, providing a full explanation of the API's purpose, functionalities, and general usage instructions. |
1052-
| `tags` | `List[str]` | A collection of tags that categorize endpoints for better organization and navigation within the documentation. This can group endpoints by their functionality or other criteria. |
1053-
| `servers` | `List[Server]` | An array of Server objects, which specify the URL to the server and a description for its environment (production, staging, development, etc.), providing connectivity information. |
1054-
| `terms_of_service` | `str` | A URL that points to the terms of service for your API. This could provide legal information and user responsibilities related to the usage of the API. |
1055-
| `contact` | `Contact` | A Contact object containing contact details of the organization or individuals maintaining the API. This may include fields such as name, URL, and email. |
1056-
| `license_info` | `License` | A License object providing the license details for the API, typically including the name of the license and the URL to the full license text. |
1048+
| Field Name | Type | Description |
1049+
| ------------------ | -------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1050+
| `title` | `str` | The title for your API. It should be a concise, specific name that can be used to identify the API in documentation or listings. |
1051+
| `version` | `str` | The version of the API you are documenting. This could reflect the release iteration of the API and helps clients understand the evolution of the API. |
1052+
| `openapi_version` | `str` | Specifies the version of the OpenAPI Specification on which your API is based. When using Pydantic v1 it defaults to 3.0.3, and when using Pydantic v2, it defaults to 3.1.0. |
1053+
| `summary` | `str` | A short and informative summary that can provide an overview of what the API does. This can be the same as or different from the title but should add context or information. **Not supported when using Pydantic v1** |
1054+
| `description` | `str` | A verbose description that can include Markdown formatting, providing a full explanation of the API's purpose, functionalities, and general usage instructions. |
1055+
| `tags` | `List[str]` | A collection of tags that categorize endpoints for better organization and navigation within the documentation. This can group endpoints by their functionality or other criteria. |
1056+
| `servers` | `List[Server]` | An array of Server objects, which specify the URL to the server and a description for its environment (production, staging, development, etc.), providing connectivity information. |
1057+
| `terms_of_service` | `str` | A URL that points to the terms of service for your API. This could provide legal information and user responsibilities related to the usage of the API. |
1058+
| `contact` | `Contact` | A Contact object containing contact details of the organization or individuals maintaining the API. This may include fields such as name, URL, and email. |
1059+
| `license_info` | `License` | A License object providing the license details for the API, typically including the name of the license and the URL to the full license text. |
10571060

10581061
Include extra parameters when exporting your OpenAPI specification to apply these customizations:
10591062

tests/functional/event_handler/conftest.py

+43
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22

3+
import fastjsonschema
34
import pytest
45

56
from tests.functional.utils import load_event
@@ -71,3 +72,45 @@ def gw_event_vpc_lattice():
7172
@pytest.fixture
7273
def gw_event_vpc_lattice_v1():
7374
return load_event("vpcLatticeEvent.json")
75+
76+
77+
@pytest.fixture(scope="session")
78+
def pydanticv1_only():
79+
from pydantic import __version__
80+
81+
version = __version__.split(".")
82+
if version[0] != "1":
83+
pytest.skip("pydanticv1 test only")
84+
85+
86+
@pytest.fixture(scope="session")
87+
def pydanticv2_only():
88+
from pydantic import __version__
89+
90+
version = __version__.split(".")
91+
if version[0] != "2":
92+
pytest.skip("pydanticv2 test only")
93+
94+
95+
@pytest.fixture(scope="session")
96+
def openapi30_schema():
97+
from urllib.request import urlopen
98+
99+
f = urlopen("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json")
100+
data = json.loads(f.read().decode("utf-8"))
101+
return fastjsonschema.compile(
102+
data,
103+
use_formats=False,
104+
)
105+
106+
107+
@pytest.fixture(scope="session")
108+
def openapi31_schema():
109+
from urllib.request import urlopen
110+
111+
f = urlopen("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.1/schema.json")
112+
data = json.loads(f.read().decode("utf-8"))
113+
return fastjsonschema.compile(
114+
data,
115+
use_formats=False,
116+
)

tests/functional/event_handler/test_openapi_encoders.py

-9
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,6 @@
99
from aws_lambda_powertools.event_handler.openapi.encoders import jsonable_encoder
1010

1111

12-
@pytest.fixture
13-
def pydanticv1_only():
14-
from pydantic import __version__
15-
16-
version = __version__.split(".")
17-
if version[0] != "1":
18-
pytest.skip("pydanticv1 test only")
19-
20-
2112
def test_openapi_encode_include():
2213
class User(BaseModel):
2314
name: str
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import json
2+
import warnings
3+
from typing import Optional
4+
5+
import pytest
6+
from pydantic import BaseModel, Field
7+
8+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
9+
from aws_lambda_powertools.event_handler.openapi.models import Contact, License, Server
10+
from aws_lambda_powertools.event_handler.openapi.params import Query
11+
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse
12+
from aws_lambda_powertools.shared.types import Annotated, Literal
13+
14+
15+
@pytest.mark.usefixtures("pydanticv1_only")
16+
def test_openapi_3_0_simple_handler(openapi30_schema):
17+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
18+
app = APIGatewayRestResolver(enable_validation=True)
19+
20+
# WHEN we have a simple handler
21+
@app.get("/")
22+
def handler():
23+
pass
24+
25+
# WHEN we get the schema
26+
schema = json.loads(app.get_openapi_json_schema())
27+
28+
# THEN the schema should be valid
29+
assert openapi30_schema(schema)
30+
31+
32+
@pytest.mark.usefixtures("pydanticv1_only")
33+
def test_openapi_3_1_with_pydantic_v1():
34+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
35+
app = APIGatewayRestResolver(enable_validation=True)
36+
37+
# WHEN we get the schema
38+
with warnings.catch_warnings(record=True) as w:
39+
warnings.simplefilter("default")
40+
app.get_openapi_json_schema(openapi_version="3.1.0")
41+
assert len(w) == 1
42+
assert str(w[-1].message) == (
43+
"You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0"
44+
)
45+
46+
47+
@pytest.mark.usefixtures("pydanticv1_only")
48+
def test_openapi_3_0_complex_handler(openapi30_schema):
49+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
50+
app = APIGatewayRestResolver(enable_validation=True)
51+
52+
# GIVEN a complex pydantic model
53+
class TodoAttributes(BaseModel):
54+
userId: int
55+
id_: Optional[int] = Field(alias="id", default=None)
56+
title: str
57+
completed: bool
58+
59+
class Todo(BaseModel):
60+
type: Literal["ingest"]
61+
attributes: TodoAttributes
62+
63+
class TodoEnvelope(BaseModel):
64+
data: Annotated[Todo, Field(description="The todo")]
65+
66+
# WHEN we have a complex handler
67+
@app.get(
68+
"/",
69+
summary="This is a summary",
70+
description="Gets todos",
71+
tags=["users", "operations", "todos"],
72+
responses={
73+
204: OpenAPIResponse(
74+
description="Successful creation",
75+
content={"": {"schema": {}}},
76+
),
77+
},
78+
)
79+
def handler(
80+
name: Annotated[str, Query(description="The name", min_length=10, max_length=20)] = "John Doe Junior",
81+
) -> TodoEnvelope: ...
82+
83+
@app.post(
84+
"/todos",
85+
tags=["todo"],
86+
responses={
87+
204: OpenAPIResponse(
88+
description="Successful creation",
89+
content={"": {"schema": {}}},
90+
),
91+
},
92+
)
93+
def create_todo(todo: TodoEnvelope): ...
94+
95+
# WHEN we get the schema
96+
schema = json.loads(
97+
app.get_openapi_json_schema(
98+
title="My little API",
99+
version="69",
100+
openapi_version="3.1.0",
101+
summary="API Summary",
102+
description="API description",
103+
tags=["api"],
104+
servers=[Server(url="http://localhost")],
105+
terms_of_service="Yes",
106+
contact=Contact(name="John Smith"),
107+
license_info=License(name="MIT"),
108+
),
109+
)
110+
111+
# THEN the schema should be valid
112+
assert openapi30_schema(schema)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import json
2+
import warnings
3+
from typing import Optional
4+
5+
import pytest
6+
from pydantic import BaseModel, Field
7+
8+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
9+
from aws_lambda_powertools.event_handler.openapi.models import Contact, License, Server
10+
from aws_lambda_powertools.event_handler.openapi.params import Query
11+
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse
12+
from aws_lambda_powertools.shared.types import Annotated, Literal
13+
14+
15+
@pytest.mark.usefixtures("pydanticv2_only")
16+
def test_openapi_3_1_simple_handler(openapi31_schema):
17+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
18+
app = APIGatewayRestResolver(enable_validation=True)
19+
20+
# WHEN we have a simple handler
21+
@app.get("/")
22+
def handler():
23+
pass
24+
25+
# WHEN we get the schema
26+
schema = json.loads(app.get_openapi_json_schema())
27+
28+
# THEN the schema should be valid
29+
assert openapi31_schema(schema)
30+
31+
32+
@pytest.mark.usefixtures("pydanticv2_only")
33+
def test_openapi_3_0_with_pydantic_v2():
34+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
35+
app = APIGatewayRestResolver(enable_validation=True)
36+
37+
# WHEN we get the schema
38+
with warnings.catch_warnings(record=True) as w:
39+
warnings.simplefilter("default")
40+
app.get_openapi_json_schema(openapi_version="3.0.0")
41+
assert len(w) == 1
42+
assert str(w[-1].message) == (
43+
"You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1"
44+
)
45+
46+
47+
@pytest.mark.usefixtures("pydanticv2_only")
48+
def test_openapi_3_1_complex_handler(openapi31_schema):
49+
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
50+
app = APIGatewayRestResolver(enable_validation=True)
51+
52+
# GIVEN a complex pydantic model
53+
class TodoAttributes(BaseModel):
54+
userId: int
55+
id_: Optional[int] = Field(alias="id", default=None)
56+
title: str
57+
completed: bool
58+
59+
class Todo(BaseModel):
60+
type: Literal["ingest"]
61+
attributes: TodoAttributes
62+
63+
class TodoEnvelope(BaseModel):
64+
data: Annotated[Todo, Field(description="The todo")]
65+
66+
# WHEN we have a complex handler
67+
@app.get(
68+
"/",
69+
summary="This is a summary",
70+
description="Gets todos",
71+
tags=["users", "operations", "todos"],
72+
responses={
73+
204: OpenAPIResponse(
74+
description="Successful creation",
75+
content={"": {"schema": {}}},
76+
),
77+
},
78+
)
79+
def handler(
80+
name: Annotated[str, Query(description="The name", min_length=10, max_length=20)] = "John Doe Junior",
81+
) -> TodoEnvelope: ...
82+
83+
@app.post(
84+
"/todos",
85+
tags=["todo"],
86+
responses={
87+
204: OpenAPIResponse(
88+
description="Successful creation",
89+
content={"": {"schema": {}}},
90+
),
91+
},
92+
)
93+
def create_todo(todo: TodoEnvelope): ...
94+
95+
# WHEN we get the schema
96+
schema = json.loads(
97+
app.get_openapi_json_schema(
98+
title="My little API",
99+
version="69",
100+
openapi_version="3.1.0",
101+
summary="API Summary",
102+
description="API description",
103+
tags=["api"],
104+
servers=[Server(url="http://localhost")],
105+
terms_of_service="Yes",
106+
contact=Contact(name="John Smith"),
107+
license_info=License(name="MIT"),
108+
),
109+
)
110+
111+
# THEN the schema should be valid
112+
assert openapi31_schema(schema)

0 commit comments

Comments
 (0)