Skip to content
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

fix: correct the format of the OpenAPI examples #2660

Merged
merged 12 commits into from
Nov 13, 2023
8 changes: 4 additions & 4 deletions litestar/_openapi/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import TYPE_CHECKING

from litestar._openapi.schema_generation.utils import get_formatted_examples
from litestar.constants import RESERVED_KWARGS
from litestar.enums import ParamType
from litestar.exceptions import ImproperlyConfiguredException
Expand All @@ -18,7 +19,7 @@
from litestar._openapi.schema_generation import SchemaCreator
from litestar.di import Provide
from litestar.handlers.base import BaseRouteHandler
from litestar.openapi.spec import Example, Reference
from litestar.openapi.spec import Reference
from litestar.types.internal_types import PathParameterDefinition


Expand Down Expand Up @@ -109,9 +110,8 @@ def create_parameter(

schema = result if isinstance(result, Schema) else schema_creator.schemas[result.value]

examples: dict[str, Example | Reference] = {}
for i, example in enumerate(kwarg_definition.examples or [] if kwarg_definition else []):
examples[f"{field_definition.name}-example-{i}"] = example
examples_list = kwarg_definition.examples or [] if kwarg_definition else []
examples = get_formatted_examples(field_definition, examples_list)

return Parameter(
description=schema.description,
Expand Down
6 changes: 4 additions & 2 deletions litestar/_openapi/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from litestar.enums import MediaType
from litestar.exceptions import HTTPException, ValidationException
from litestar.openapi.spec import OpenAPIResponse
from litestar.openapi.spec import Example, OpenAPIResponse
from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
from litestar.openapi.spec.header import OpenAPIHeader
from litestar.openapi.spec.media_type import OpenAPIMediaType
Expand Down Expand Up @@ -214,7 +214,9 @@ def create_error_responses(exceptions: list[type[HTTPException]]) -> Iterator[tu
),
},
description=pascal_case_to_text(get_name(exc)),
examples=[{"status_code": status_code, "detail": example_detail, "extra": {}}],
examples={
exc.__name__: Example(value={"status_code": status_code, "detail": example_detail, "extra": {}})
},
)
)
if len(exceptions_schemas) > 1: # noqa: SIM108
Expand Down
7 changes: 6 additions & 1 deletion litestar/_openapi/schema_generation/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
_should_create_enum_schema,
_should_create_literal_schema,
_type_or_first_not_none_inner_type,
get_formatted_examples,
)
from litestar.datastructures.upload_file import UploadFile
from litestar.exceptions import ImproperlyConfiguredException
Expand All @@ -72,6 +73,7 @@
if TYPE_CHECKING:
from msgspec.structs import FieldInfo

from litestar.openapi.spec import Example
from litestar.plugins import OpenAPISchemaPluginProtocol

KWARG_DEFINITION_ATTRIBUTE_TO_OPENAPI_PROPERTY_MAP: dict[str, str] = {
Expand Down Expand Up @@ -639,12 +641,15 @@ def process_schema_result(self, field: FieldDefinition, schema: Schema) -> Schem
if (value := getattr(field.kwarg_definition, kwarg_definition_key, Empty)) and (
not isinstance(value, Hashable) or not self.is_undefined(value)
):
if schema_key == "examples":
value = get_formatted_examples(field, cast("list[Example]", value))

setattr(schema, schema_key, value)

if not schema.examples and self.generate_examples:
from litestar._openapi.schema_generation.examples import create_examples_for_field

schema.examples = create_examples_for_field(field)
schema.examples = get_formatted_examples(field, create_examples_for_field(field))

if schema.title and schema.type in (OpenAPIType.OBJECT, OpenAPIType.ARRAY):
if schema.title in self.schemas and hash(self.schemas[schema.title]) != hash(schema):
Expand Down
16 changes: 15 additions & 1 deletion litestar/_openapi/schema_generation/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations

from enum import Enum
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Mapping

from litestar.utils.helpers import get_name

if TYPE_CHECKING:
from collections.abc import Sequence

from litestar.openapi.spec import Example
from litestar.typing import FieldDefinition

__all__ = (
Expand Down Expand Up @@ -75,3 +80,12 @@ def _should_create_literal_schema(field_definition: FieldDefinition) -> bool:
or field_definition.is_optional
and all(inner.is_literal for inner in field_definition.inner_types if not inner.is_none_type)
)


def get_formatted_examples(field_definition: FieldDefinition, examples: Sequence[Example]) -> Mapping[str, Example]:
"""Format the examples into the OpenAPI schema format."""

name = field_definition.name or get_name(field_definition.type_)
name = name.lower()

return {f"{name}-example-{i}": example for i, example in enumerate(examples, 1)}
7 changes: 6 additions & 1 deletion litestar/contrib/pydantic/pydantic_schema_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing_extensions import Annotated

from litestar._openapi.schema_generation.schema import SchemaCreator, _get_type_schema_name
from litestar._openapi.schema_generation.utils import get_formatted_examples
from litestar.contrib.pydantic.utils import (
is_pydantic_2_model,
is_pydantic_constrained_field,
Expand Down Expand Up @@ -278,5 +279,9 @@ def for_pydantic_model(cls, field_definition: FieldDefinition, schema_creator: S
properties={k: schema_creator.for_field_definition(f) for k, f in field_definitions.items()},
type=OpenAPIType.OBJECT,
title=title or _get_type_schema_name(field_definition),
examples=[Example(example)] if example else None, # type: ignore[arg-type]
examples=get_formatted_examples(
field_definition, [Example(description=f"Example {field_definition.name} value", value=example)]
)
if example
else None,
)
4 changes: 2 additions & 2 deletions litestar/openapi/spec/parameter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Mapping

from litestar.openapi.spec.base import BaseSchemaObject

Expand Down Expand Up @@ -117,7 +117,7 @@ class Parameter(BaseSchemaObject):
necessary.
"""

examples: dict[str, Example | Reference] | None = None
examples: Mapping[str, Example | Reference] | None = None
"""Examples of the parameter's potential value. Each example SHOULD contain a value in the correct format as
specified in the parameter encoding. The ``examples`` field is mutually exclusive of the ``example`` field.
Furthermore, if referencing a ``schema`` that contains an example, the ``examples`` value SHALL _override_ the
Expand Down
17 changes: 5 additions & 12 deletions litestar/openapi/spec/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
if TYPE_CHECKING:
from litestar.openapi.spec.discriminator import Discriminator
from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType
from litestar.openapi.spec.example import Example
from litestar.openapi.spec.external_documentation import ExternalDocumentation
from litestar.openapi.spec.reference import Reference
from litestar.openapi.spec.xml import XML
Expand Down Expand Up @@ -609,19 +610,11 @@ class Schema(BaseSchemaObject):
Omitting these keywords has the same behavior as values of false.
"""

examples: Sequence[Any] | None = None
"""The value of this keyword MUST be an array. There are no restrictions placed on the values within the array.
When multiple occurrences of this keyword are applicable to a single sub-instance, implementations MUST provide a
flat array of all values rather than an array of arrays.
examples: Mapping[str, Example] | None = None
"""The value of this must be an array containing the example values directly or a mapping of string
to an ``Example`` instance.

This keyword can be used to provide sample JSON values associated with a particular schema, for the purpose of
illustrating usage. It is RECOMMENDED that these values be valid against the associated
schema.

Implementations MAY use the value(s) of "default", if present, as an additional example. If "examples" is absent,
"default" MAY still be used in this manner.

The OpenAPI Specification's base vocabulary is comprised of the following keywords:
This is based on the ``examples`` keyword of JSON Schema.
"""

discriminator: Discriminator | None = None
Expand Down
6 changes: 6 additions & 0 deletions litestar/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@ def is_typeddict_type(self) -> bool:

return is_typeddict(self.origin or self.annotation)

@property
def type_(self) -> Any:
cofin marked this conversation as resolved.
Show resolved Hide resolved
"""The type of the annotation with all the wrappers removed, including the generic types."""

return self.origin or self.annotation

def is_subclass_of(self, cl: type[Any] | tuple[type[Any], ...]) -> bool:
"""Whether the annotation is a subclass of the given type.

Expand Down
26 changes: 18 additions & 8 deletions tests/unit/test_contrib/test_pydantic/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ async def example_route() -> Lookup:
assert response.status_code == HTTP_200_OK
assert response.json()["components"]["schemas"]["Lookup"]["properties"]["id"] == {
"description": "A unique identifier",
"examples": [{"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}],
"examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}},
"maxLength": 16,
"minLength": 12,
"type": "string",
Expand Down Expand Up @@ -413,7 +413,7 @@ async def example_route() -> Lookup:
assert response.status_code == HTTP_200_OK
assert response.json()["components"]["schemas"]["Lookup"]["properties"]["id"] == {
"description": "A unique identifier",
"examples": [{"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}],
"examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}},
"maxLength": 16,
"minLength": 12,
"type": "string",
Expand Down Expand Up @@ -508,9 +508,14 @@ class Model(pydantic_v1.BaseModel):
SchemaCreator(schemas=schemas, plugins=[PydanticSchemaPlugin()]).for_field_definition(field_definition)
schema = schemas["Model"]

assert schema.properties["value"].description == "description" # type: ignore
assert schema.properties["value"].title == "title" # type: ignore
assert schema.properties["value"].examples == [Example(value="example")] # type: ignore
assert schema.properties

value = schema.properties["value"]

assert isinstance(value, Schema)
assert value.description == "description"
assert value.title == "title"
assert value.examples == {"value-example-1": Example(value="example")}


def test_create_schema_for_field_v2() -> None:
Expand All @@ -524,9 +529,14 @@ class Model(pydantic_v2.BaseModel):
SchemaCreator(schemas=schemas, plugins=[PydanticSchemaPlugin()]).for_field_definition(field_definition)
schema = schemas["Model"]

assert schema.properties["value"].description == "description" # type: ignore
assert schema.properties["value"].title == "title" # type: ignore
assert schema.properties["value"].examples == [Example(value="example")] # type: ignore
assert schema.properties

value = schema.properties["value"]

assert isinstance(value, Schema)
assert value.description == "description"
assert value.title == "title"
assert value.examples == {"value-example-1": Example(value="example")}


@pytest.mark.parametrize("with_future_annotations", [True, False])
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_openapi/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ async def example_route() -> Lookup:
assert response.status_code == HTTP_200_OK
assert response.json()["components"]["schemas"]["Lookup"]["properties"]["id"] == {
"description": "A unique identifier",
"examples": [{"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}],
"examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}},
"maxLength": 16,
"minLength": 12,
"type": "string",
Expand Down
12 changes: 6 additions & 6 deletions tests/unit/test_openapi/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_create_parameters(person_controller: Type[Controller]) -> None:
assert page_size.required
assert page_size.description == "Page Size Description"
assert page_size.schema.examples
assert page_size.schema.examples[0].value == 1
assert next(iter(page_size.schema.examples.values())).value == 1

assert name.param_in == ParamType.QUERY
assert name.name == "name"
Expand Down Expand Up @@ -107,19 +107,19 @@ def test_create_parameters(person_controller: Type[Controller]) -> None:
Schema(
type=OpenAPIType.STRING,
enum=["M", "F", "O", "A"],
examples=[Example(description="Example value", value="M")],
examples={"gender-example-1": Example(description="Example value", value="M")},
),
Schema(
type=OpenAPIType.ARRAY,
items=Schema(
type=OpenAPIType.STRING,
enum=["M", "F", "O", "A"],
examples=[Example(description="Example value", value="F")],
examples={"gender-example-1": Example(description="Example value", value="F")},
),
examples=[Example(description="Example value", value=["A"])],
examples={"list-example-1": Example(description="Example value", value=["A"])},
),
],
examples=[Example(value="M"), Example(value=["M", "O"])],
examples={"gender-example-1": Example(value="M"), "gender-example-2": Example(value=["M", "O"])},
)
assert not gender.required

Expand Down Expand Up @@ -323,5 +323,5 @@ async def index(
) as client:
response = client.get("/schema/openapi.json")
assert response.json()["paths"]["/"]["get"]["parameters"][0]["examples"] == {
"text-example-0": {"summary": "example summary", "value": "example value"}
"text-example-1": {"summary": "example summary", "value": "example value"}
}
15 changes: 10 additions & 5 deletions tests/unit/test_openapi/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,15 @@ def test_process_schema_result() -> None:
field = FieldDefinition.from_annotation(annotation=str, kwarg_definition=kwarg_definition)
schema = SchemaCreator().for_field_definition(field)

assert schema.title # type: ignore
assert schema.const == test_str # type: ignore
assert isinstance(schema, Schema)
assert schema.title
assert schema.const == test_str
assert kwarg_definition.examples
for signature_key, schema_key in KWARG_DEFINITION_ATTRIBUTE_TO_OPENAPI_PROPERTY_MAP.items():
assert getattr(schema, schema_key) == getattr(kwarg_definition, signature_key)
if schema_key == "examples":
assert schema.examples == {"str-example-1": kwarg_definition.examples[0]}
else:
assert getattr(schema, schema_key) == getattr(kwarg_definition, signature_key)


def test_dependency_schema_generation() -> None:
Expand Down Expand Up @@ -172,7 +177,7 @@ def test_schema_hashing() -> None:
Schema(type=OpenAPIType.NUMBER),
Schema(type=OpenAPIType.OBJECT, properties={"key": Schema(type=OpenAPIType.STRING)}),
],
examples=[Example(value=None), Example(value=[1, 2, 3])],
examples={"example-1": Example(value=None), "example-2": Example(value=[1, 2, 3])},
)
assert hash(schema)

Expand Down Expand Up @@ -249,7 +254,7 @@ class Lookup(msgspec.Struct):
schema = schemas["Lookup"]

assert schema.properties["id"].type == OpenAPIType.STRING # type: ignore
assert schema.properties["id"].examples == [Example(value="example")] # type: ignore
assert schema.properties["id"].examples == {"id-example-1": Example(value="example")} # type: ignore
assert schema.properties["id"].description == "description" # type: ignore
assert schema.properties["id"].title == "title" # type: ignore
assert schema.properties["id"].max_length == 16 # type: ignore
Expand Down