Skip to content

fix(event_handler): OpenAPI schema version respects Pydantic version #3860

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 1, 2024
15 changes: 15 additions & 0 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1455,10 +1455,25 @@ def get_openapi_schema(
get_definitions,
)
from aws_lambda_powertools.event_handler.openapi.models import OpenAPI, PathItem, Server, Tag
from aws_lambda_powertools.event_handler.openapi.pydantic_loader import PYDANTIC_V2
from aws_lambda_powertools.event_handler.openapi.types import (
COMPONENT_REF_TEMPLATE,
)

# Pydantic V2 has no support for OpenAPI schema 3.0
if PYDANTIC_V2 and not openapi_version.startswith("3.1"):
warnings.warn(
"You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1",
stacklevel=2,
)
openapi_version = "3.1.0"
elif not PYDANTIC_V2 and not openapi_version.startswith("3.0"):
warnings.warn(
"You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0",
stacklevel=2,
)
openapi_version = "3.0.3"

# Start with the bare minimum required for a valid OpenAPI schema
info: Dict[str, Any] = {"title": title, "version": version}

Expand Down
2 changes: 1 addition & 1 deletion aws_lambda_powertools/event_handler/openapi/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
DEFAULT_API_VERSION = "1.0.0"
DEFAULT_OPENAPI_VERSION = "3.0.0"
DEFAULT_OPENAPI_VERSION = "3.0.3"
6 changes: 3 additions & 3 deletions aws_lambda_powertools/event_handler/openapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,20 @@ class Config:
# https://swagger.io/specification/#info-object
class Info(BaseModel):
title: str
summary: Optional[str] = None
description: Optional[str] = None
termsOfService: Optional[str] = None
contact: Optional[Contact] = None
license: Optional[License] = None # noqa: A003
version: str

if PYDANTIC_V2:
model_config = {"extra": "allow"}
summary: Optional[str] = None
model_config = {"extra": "ignore"}

else:

class Config:
extra = "allow"
extra = "ignore"


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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

29 changes: 16 additions & 13 deletions docs/core/event_handler/api_gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,10 @@ This will enable full tracebacks errors in the response, print request and respo

### OpenAPI

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.
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.

???+ warning "OpenAPI schema version depends on the installed version of Pydantic"
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"}.

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.

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

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

| Field Name | Type | Description |
| ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
| `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. |
| `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. |
| `description` | `str` | A verbose description that can include Markdown formatting, providing a full explanation of the API's purpose, functionalities, and general usage instructions. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| Field Name | Type | Description |
| ------------------ | -------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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. |
| `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. |
| `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. |
| `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** |
| `description` | `str` | A verbose description that can include Markdown formatting, providing a full explanation of the API's purpose, functionalities, and general usage instructions. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |

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

Expand Down
43 changes: 43 additions & 0 deletions tests/functional/event_handler/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json

import fastjsonschema
import pytest

from tests.functional.utils import load_event
Expand Down Expand Up @@ -71,3 +72,45 @@ def gw_event_vpc_lattice():
@pytest.fixture
def gw_event_vpc_lattice_v1():
return load_event("vpcLatticeEvent.json")


@pytest.fixture(scope="session")
def pydanticv1_only():
from pydantic import __version__

version = __version__.split(".")
if version[0] != "1":
pytest.skip("pydanticv1 test only")


@pytest.fixture(scope="session")
def pydanticv2_only():
from pydantic import __version__

version = __version__.split(".")
if version[0] != "2":
pytest.skip("pydanticv2 test only")


@pytest.fixture(scope="session")
def openapi30_schema():
from urllib.request import urlopen

f = urlopen("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json")
data = json.loads(f.read().decode("utf-8"))
return fastjsonschema.compile(
data,
use_formats=False,
)


@pytest.fixture(scope="session")
def openapi31_schema():
from urllib.request import urlopen

f = urlopen("https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.1/schema.json")
data = json.loads(f.read().decode("utf-8"))
return fastjsonschema.compile(
data,
use_formats=False,
)
9 changes: 0 additions & 9 deletions tests/functional/event_handler/test_openapi_encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,6 @@
from aws_lambda_powertools.event_handler.openapi.encoders import jsonable_encoder


@pytest.fixture
def pydanticv1_only():
from pydantic import __version__

version = __version__.split(".")
if version[0] != "1":
pytest.skip("pydanticv1 test only")


def test_openapi_encode_include():
class User(BaseModel):
name: str
Expand Down
112 changes: 112 additions & 0 deletions tests/functional/event_handler/test_openapi_schema_pydantic_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json
import warnings
from typing import Optional

import pytest
from pydantic import BaseModel, Field

from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.event_handler.openapi.models import Contact, License, Server
from aws_lambda_powertools.event_handler.openapi.params import Query
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse
from aws_lambda_powertools.shared.types import Annotated, Literal


@pytest.mark.usefixtures("pydanticv1_only")
def test_openapi_3_0_simple_handler(openapi30_schema):
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# WHEN we have a simple handler
@app.get("/")
def handler():
pass

# WHEN we get the schema
schema = json.loads(app.get_openapi_json_schema())

# THEN the schema should be valid
assert openapi30_schema(schema)


@pytest.mark.usefixtures("pydanticv1_only")
def test_openapi_3_1_with_pydantic_v1():
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# WHEN we get the schema
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("default")
app.get_openapi_json_schema(openapi_version="3.1.0")
assert len(w) == 1
assert str(w[-1].message) == (
"You are using Pydantic v1, which is incompatible with OpenAPI schema 3.1. Forcing OpenAPI 3.0"
)


@pytest.mark.usefixtures("pydanticv1_only")
def test_openapi_3_0_complex_handler(openapi30_schema):
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# GIVEN a complex pydantic model
class TodoAttributes(BaseModel):
userId: int
id_: Optional[int] = Field(alias="id", default=None)
title: str
completed: bool

class Todo(BaseModel):
type: Literal["ingest"]
attributes: TodoAttributes

class TodoEnvelope(BaseModel):
data: Annotated[Todo, Field(description="The todo")]

# WHEN we have a complex handler
@app.get(
"/",
summary="This is a summary",
description="Gets todos",
tags=["users", "operations", "todos"],
responses={
204: OpenAPIResponse(
description="Successful creation",
content={"": {"schema": {}}},
),
},
)
def handler(
name: Annotated[str, Query(description="The name", min_length=10, max_length=20)] = "John Doe Junior",
) -> TodoEnvelope: ...

@app.post(
"/todos",
tags=["todo"],
responses={
204: OpenAPIResponse(
description="Successful creation",
content={"": {"schema": {}}},
),
},
)
def create_todo(todo: TodoEnvelope): ...

# WHEN we get the schema
schema = json.loads(
app.get_openapi_json_schema(
title="My little API",
version="69",
openapi_version="3.1.0",
summary="API Summary",
description="API description",
tags=["api"],
servers=[Server(url="http://localhost")],
terms_of_service="Yes",
contact=Contact(name="John Smith"),
license_info=License(name="MIT"),
),
)

# THEN the schema should be valid
assert openapi30_schema(schema)
112 changes: 112 additions & 0 deletions tests/functional/event_handler/test_openapi_schema_pydantic_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json
import warnings
from typing import Optional

import pytest
from pydantic import BaseModel, Field

from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.event_handler.openapi.models import Contact, License, Server
from aws_lambda_powertools.event_handler.openapi.params import Query
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse
from aws_lambda_powertools.shared.types import Annotated, Literal


@pytest.mark.usefixtures("pydanticv2_only")
def test_openapi_3_1_simple_handler(openapi31_schema):
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# WHEN we have a simple handler
@app.get("/")
def handler():
pass

# WHEN we get the schema
schema = json.loads(app.get_openapi_json_schema())

# THEN the schema should be valid
assert openapi31_schema(schema)


@pytest.mark.usefixtures("pydanticv2_only")
def test_openapi_3_0_with_pydantic_v2():
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# WHEN we get the schema
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("default")
app.get_openapi_json_schema(openapi_version="3.0.0")
assert len(w) == 1
assert str(w[-1].message) == (
"You are using Pydantic v2, which is incompatible with OpenAPI schema 3.0. Forcing OpenAPI 3.1"
)


@pytest.mark.usefixtures("pydanticv2_only")
def test_openapi_3_1_complex_handler(openapi31_schema):
# GIVEN APIGatewayRestResolver is initialized with enable_validation=True
app = APIGatewayRestResolver(enable_validation=True)

# GIVEN a complex pydantic model
class TodoAttributes(BaseModel):
userId: int
id_: Optional[int] = Field(alias="id", default=None)
title: str
completed: bool

class Todo(BaseModel):
type: Literal["ingest"]
attributes: TodoAttributes

class TodoEnvelope(BaseModel):
data: Annotated[Todo, Field(description="The todo")]

# WHEN we have a complex handler
@app.get(
"/",
summary="This is a summary",
description="Gets todos",
tags=["users", "operations", "todos"],
responses={
204: OpenAPIResponse(
description="Successful creation",
content={"": {"schema": {}}},
),
},
)
def handler(
name: Annotated[str, Query(description="The name", min_length=10, max_length=20)] = "John Doe Junior",
) -> TodoEnvelope: ...

@app.post(
"/todos",
tags=["todo"],
responses={
204: OpenAPIResponse(
description="Successful creation",
content={"": {"schema": {}}},
),
},
)
def create_todo(todo: TodoEnvelope): ...

# WHEN we get the schema
schema = json.loads(
app.get_openapi_json_schema(
title="My little API",
version="69",
openapi_version="3.1.0",
summary="API Summary",
description="API description",
tags=["api"],
servers=[Server(url="http://localhost")],
terms_of_service="Yes",
contact=Contact(name="John Smith"),
license_info=License(name="MIT"),
),
)

# THEN the schema should be valid
assert openapi31_schema(schema)