Skip to content

Commit

Permalink
fix #3722
Browse files Browse the repository at this point in the history
  • Loading branch information
provinzkraut committed Sep 15, 2024
1 parent b89aea7 commit ea48f03
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 91 deletions.
103 changes: 56 additions & 47 deletions litestar/contrib/pydantic/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def pydantic_get_type_hints_with_generics_resolved(


@deprecated(version="2.6.2")
def pydantic_get_unwrapped_annotation_and_type_hints(annotation: Any) -> tuple[Any, dict[str, Any]]: # pragma: pver
def pydantic_get_unwrapped_annotation_and_type_hints(annotation: Any) -> tuple[Any, dict[str, Any]]: # pragma: no cover
"""Get the unwrapped annotation and the type hints after resolving generics.
Args:
Expand Down Expand Up @@ -258,18 +258,21 @@ class PydanticModelInfo:
def _create_field_definition_v1( # noqa: C901
field_annotation: Any,
*,
field_info: pydantic_v1.fields.FieldInfo | None = None,
field_info: pydantic_v1.fields.FieldInfo,
**field_definition_kwargs: Any,
) -> FieldDefinition:
kwargs: dict[str, Any] = {}
if field_info:
if example := field_info.extra.get("example"):
examples = [Example(value=example)]
kwargs["examples"] = examples
if title := field_info.title:
kwargs["title"] = title
if description := field_info.description:
kwargs["description"] = description
examples: list[Any] = []
if example := field_info.extra.get("example"):
examples.append(example)
if extra_examples := field_info.extra.get("examples"):
examples.extend(extra_examples)
if examples:
kwargs["examples"] = [Example(value=e) for e in examples]
if title := field_info.title:
kwargs["title"] = title
if description := field_info.description:
kwargs["description"] = description

kwarg_definition: KwargDefinition | None = None

Expand Down Expand Up @@ -353,49 +356,55 @@ def _create_field_definition_v1( # noqa: C901
def _create_field_definition_v2( # noqa: C901
field_annotation: Any,
*,
field_info: pydantic_v2.fields.FieldInfo | None = None,
field_info: pydantic_v2.fields.FieldInfo,
**field_definition_kwargs: Any,
) -> FieldDefinition:
kwargs: dict[str, Any] = {}
examples: list[Any] = []
field_meta: list[Any] = []

if field_info:
if json_schema_extra := field_info.json_schema_extra:
if callable(json_schema_extra):
raise ValueError("Callable not supported for examples")
if json_schema_example := json_schema_extra.get("example"):
examples.append(json_schema_example)
if json_schema_examples := json_schema_extra.get("examples"):
examples.extend(json_schema_examples) # type: ignore[arg-type]
if field_examples := field_info.examples:
examples.extend(field_examples)

if examples:
kwargs["examples"] = [Example(value=e) for e in examples]

if description := field_info.description:
kwargs["description"] = description

if title := field_info.title:
kwargs["title"] = title

for meta in field_info.metadata:
if isinstance(meta, pydantic_v2.types.StringConstraints):
kwargs["min_length"] = meta.min_length
kwargs["max_length"] = meta.max_length
kwargs["pattern"] = meta.pattern
kwargs["lower_case"] = meta.to_lower
kwargs["upper_case"] = meta.to_upper
# forward other metadata
else:
field_meta.append(meta)

kwargs = {k: v for k, v in kwargs.items() if v is not None}

if kwargs:
kwarg_definition = ParameterKwarg(**kwargs)
field_meta.append(kwarg_definition)
if json_schema_extra := field_info.json_schema_extra:
if callable(json_schema_extra):
raise ValueError("Callable not supported for json_schema_extra")
if json_schema_example := json_schema_extra.get("example"):
del json_schema_extra["example"]
examples.append(json_schema_example)
if json_schema_examples := json_schema_extra.get("examples"):
del json_schema_extra["examples"]
examples.extend(json_schema_examples) # type: ignore[arg-type]
if field_examples := field_info.examples:
examples.extend(field_examples)

if examples:
if not json_schema_extra:
json_schema_extra = {}
json_schema_extra["examples"] = examples

if description := field_info.description:
kwargs["description"] = description

if title := field_info.title:
kwargs["title"] = title

for meta in field_info.metadata:
if isinstance(meta, pydantic_v2.types.StringConstraints):
kwargs["min_length"] = meta.min_length
kwargs["max_length"] = meta.max_length
kwargs["pattern"] = meta.pattern
kwargs["lower_case"] = meta.to_lower
kwargs["upper_case"] = meta.to_upper
# forward other metadata
else:
field_meta.append(meta)

if json_schema_extra:
kwargs["schema_extra"] = json_schema_extra

kwargs = {k: v for k, v in kwargs.items() if v is not None}

if kwargs:
kwarg_definition = ParameterKwarg(**kwargs)
field_meta.append(kwarg_definition)

if field_meta:
field_definition_kwargs["raw"] = field_annotation
Expand Down
108 changes: 64 additions & 44 deletions tests/unit/test_contrib/test_pydantic/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@
from pydantic import v1 as pydantic_v1
from typing_extensions import Annotated

from litestar import Litestar, post
from litestar import Litestar, get, post
from litestar._openapi.schema_generation.schema import SchemaCreator
from litestar.contrib.pydantic import PydanticPlugin, PydanticSchemaPlugin
from litestar.openapi import OpenAPIConfig
from litestar.openapi.spec import Reference, Schema
from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
from litestar.status_codes import HTTP_200_OK
from litestar.testing import TestClient, create_test_client
from litestar.typing import FieldDefinition
from litestar.utils import is_class_and_subclass
Expand Down Expand Up @@ -491,8 +490,7 @@ def handler(data: cls) -> cls:
}


@pytest.mark.parametrize("create_examples", (True, False))
def test_schema_generation_v1(create_examples: bool) -> None:
def test_schema_generation_v1() -> None:
class Lookup(pydantic_v1.BaseModel):
id: Annotated[
str,
Expand All @@ -501,68 +499,90 @@ class Lookup(pydantic_v1.BaseModel):
max_length=16,
description="A unique identifier",
example="e4eaaaf2-d142-11e1-b3e4-080027620cdd", # pyright: ignore
examples=["31", "32"],
),
]
with_title: str = pydantic_v1.Field(title="WITH_title")

@post("/example")
async def example_route() -> Lookup:
return Lookup(id="1234567812345678")

with create_test_client(
route_handlers=[example_route],
openapi_config=OpenAPIConfig(
title="Example API",
version="1.0.0",
create_examples=create_examples,
),
signature_namespace={"Lookup": Lookup},
) as client:
response = client.get("/schema/openapi.json")
assert response.status_code == HTTP_200_OK
assert response.json()["components"]["schemas"]["test_schema_generation_v1.Lookup"]["properties"]["id"] == {
"description": "A unique identifier",
"examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd"],
"maxLength": 16,
"minLength": 12,
"type": "string",
}
return Lookup(id="1234567812345678", with_title="1")

app = Litestar([example_route])
schema = app.openapi_schema.to_schema()
lookup_schema = schema["components"]["schemas"]["test_schema_generation_v1.Lookup"]["properties"]

assert lookup_schema["id"] == {
"description": "A unique identifier",
"examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd", "31", "32"],
"maxLength": 16,
"minLength": 12,
"type": "string",
}
assert lookup_schema["with_title"] == {"title": "WITH_title", "type": "string"}


@pytest.mark.parametrize("create_examples", (True, False))
def test_schema_generation_v2(create_examples: bool) -> None:
def test_schema_generation_v2() -> None:
class Lookup(pydantic_v2.BaseModel):
id: Annotated[
str,
pydantic_v2.Field(
min_length=12,
max_length=16,
description="A unique identifier",
json_schema_extra={"example": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"},
# we expect these examples to be merged
json_schema_extra={"example": "e4eaaaf2-d142-11e1-b3e4-080027620cdd", "examples": ["31"]},
examples=["32"],
),
]
# title should work if given on the field
with_title: str = pydantic_v2.Field(title="WITH_title")
# or as an extra
with_extra_title: str = pydantic_v2.Field(json_schema_extra={"title": "WITH_extra"})

@post("/example")
async def example_route() -> Lookup:
return Lookup(id="1234567812345678")
return Lookup(id="1234567812345678", with_title="1", with_extra_title="2")

app = Litestar([example_route])
schema = app.openapi_schema.to_schema()
lookup_schema = schema["components"]["schemas"]["test_schema_generation_v2.Lookup"]["properties"]

assert lookup_schema["id"] == {
"description": "A unique identifier",
"examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd", "31", "32"],
"maxLength": 16,
"minLength": 12,
"type": "string",
}
assert lookup_schema["with_title"] == {"title": "WITH_title", "type": "string"}
assert lookup_schema["with_extra_title"] == {"title": "WITH_extra", "type": "string"}


with create_test_client(
route_handlers=[example_route],
def test_create_examples(pydantic_version: PydanticVersion) -> None:
lib = pydantic_v1 if pydantic_version == "v1" else pydantic_v2

class Model(lib.BaseModel): # type: ignore[name-defined, misc]
foo: str = lib.Field(examples=["32"])
bar: str

@get("/example")
async def handler() -> Model:
return Model(foo="1", bar="2")

app = Litestar(
[handler],
openapi_config=OpenAPIConfig(
title="Example API",
version="1.0.0",
create_examples=create_examples,
title="Test",
version="0",
create_examples=True,
),
signature_namespace={"Lookup": Lookup},
) as client:
response = client.get("/schema/openapi.json")
assert response.status_code == HTTP_200_OK
assert response.json()["components"]["schemas"]["test_schema_generation_v2.Lookup"]["properties"]["id"] == {
"description": "A unique identifier",
"examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd"],
"maxLength": 16,
"minLength": 12,
"type": "string",
}
)
schema = app.openapi_schema.to_schema()
lookup_schema = schema["components"]["schemas"]["test_create_examples.Model"]["properties"]

assert lookup_schema["foo"]["examples"] == ["32"]
assert lookup_schema["bar"]["examples"]


def test_schema_by_alias(base_model: AnyBaseModelType, pydantic_version: PydanticVersion) -> None:
Expand Down

0 comments on commit ea48f03

Please sign in to comment.