From d8606b51fc72d62cff63e592636f55273a61ea2f Mon Sep 17 00:00:00 2001 From: Tuukka Mustonen Date: Mon, 18 Mar 2024 10:54:07 +0200 Subject: [PATCH] fix: JSON schema `examples` were OpenAPI formatted The generated `examples` in *JSON schema* objects were formatted as: ```json "examples": { "some-id": { "description": "Lorem ipsum", "value": "the real beef" } } ``` However, above is OpenAPI example format, and must not be used in JSON schema objects. Schema objects follow different formatting: ```json "examples": [ "the real beef" ] ``` This is explained in https://medium.com/apis-you-wont-hate/openapi-v3-1-and-json-schema-2019-09-6862cf3db959 Schema objects spec in https://spec.openapis.org/oas/v3.1.0#schema-object. OpenAPI example format spec in https://spec.openapis.org/oas/v3.1.0#example-object. This is referenced at least from parameters, media types and components. The technical change here is to define `Schema.examples` as `list[Any]` instead of `list[Example]`. Examples can and must still be defined as `list[Example]` for OpenAPI objects (e.g. `Parameter`, `Body`) but for JSON schema `examples` the code now internally generates/converts `list[Any]` format instead. Extra confusion here comes from the OpenAPI 3.0 vs OpenAPI 3.1 difference. OpenAPI 3.0 only allowed `example` (singular) field in schema objects. OpenAPI 3.1 supports the full JSON schema 2020-12 spec and so `examples` array in schema objects. Both `example` and `examples` seem to be supported, though the former is marked as deprecated in the latest specs. This can be tested over at https://editor-next.swagger.io by loading up the OpenAPI 3.1 Pet store example. Then add `examples` in `components.schemas.Pet` using the both ways and see the Swagger UI only render the example once it's properly formatted (it ignores is otherwise). --- litestar/_openapi/responses.py | 4 +--- litestar/_openapi/schema_generation/schema.py | 6 +++--- litestar/_openapi/schema_generation/utils.py | 13 +++++++++++- .../pydantic/pydantic_schema_plugin.py | 11 ++-------- litestar/openapi/spec/schema.py | 3 +-- litestar/typing.py | 20 +++++++++++++++---- .../test_pydantic/test_openapi.py | 10 +++++----- tests/unit/test_openapi/test_integration.py | 2 +- tests/unit/test_openapi/test_parameters.py | 12 +++++------ tests/unit/test_openapi/test_schema.py | 4 ++-- 10 files changed, 49 insertions(+), 36 deletions(-) diff --git a/litestar/_openapi/responses.py b/litestar/_openapi/responses.py index 7701d02659..6b0f312d3c 100644 --- a/litestar/_openapi/responses.py +++ b/litestar/_openapi/responses.py @@ -298,9 +298,7 @@ def create_error_responses(exceptions: list[type[HTTPException]]) -> Iterator[tu ), }, description=pascal_case_to_text(get_name(exc)), - examples={ - exc.__name__: Example(value={"status_code": status_code, "detail": example_detail, "extra": {}}) - }, + examples=[{"status_code": status_code, "detail": example_detail, "extra": {}}], ) ) if len(exceptions_schemas) > 1: # noqa: SIM108 diff --git a/litestar/_openapi/schema_generation/schema.py b/litestar/_openapi/schema_generation/schema.py index 2691ab12e9..7464b2331a 100644 --- a/litestar/_openapi/schema_generation/schema.py +++ b/litestar/_openapi/schema_generation/schema.py @@ -44,7 +44,7 @@ _should_create_enum_schema, _should_create_literal_schema, _type_or_first_not_none_inner_type, - get_formatted_examples, + get_json_schema_examples, ) from litestar.datastructures import UploadFile from litestar.exceptions import ImproperlyConfiguredException @@ -558,7 +558,7 @@ def process_schema_result(self, field: FieldDefinition, schema: Schema) -> Schem not isinstance(value, Hashable) or not self.is_undefined(value) ): if schema_key == "examples": - value = get_formatted_examples(field, cast("list[Example]", value)) + value = get_json_schema_examples(field, cast("list[Example]", value)) # we only want to transfer values from the `KwargDefinition` to `Schema` if the schema object # doesn't already have a value for that property. For example, if a field is a constrained date, @@ -572,7 +572,7 @@ def process_schema_result(self, field: FieldDefinition, schema: Schema) -> Schem if not schema.examples and self.generate_examples: from litestar._openapi.schema_generation.examples import create_examples_for_field - schema.examples = get_formatted_examples(field, create_examples_for_field(field)) + schema.examples = get_json_schema_examples(field, create_examples_for_field(field)) if schema.title and schema.type == OpenAPIType.OBJECT: key = _get_normalized_schema_key(field.annotation) diff --git a/litestar/_openapi/schema_generation/utils.py b/litestar/_openapi/schema_generation/utils.py index 37f7dc321c..868b1b8c1d 100644 --- a/litestar/_openapi/schema_generation/utils.py +++ b/litestar/_openapi/schema_generation/utils.py @@ -3,12 +3,12 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Mapping, _GenericAlias # type: ignore[attr-defined] +from litestar.openapi.spec import Example 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__ = ( @@ -107,3 +107,14 @@ def get_formatted_examples(field_definition: FieldDefinition, examples: Sequence name = name.lower() return {f"{name}-example-{i}": example for i, example in enumerate(examples, 1)} + + +def get_json_schema_examples(field_definition: FieldDefinition, examples: Sequence[Example | str]) -> list[Any]: + """Format the examples into the JSON schema format.""" + formatted: list[Any] = [] + for example in examples: + if isinstance(example, Example): + formatted.append(example.value) + else: + formatted.append(example) + return formatted diff --git a/litestar/contrib/pydantic/pydantic_schema_plugin.py b/litestar/contrib/pydantic/pydantic_schema_plugin.py index ffc50f13e1..2c189e4416 100644 --- a/litestar/contrib/pydantic/pydantic_schema_plugin.py +++ b/litestar/contrib/pydantic/pydantic_schema_plugin.py @@ -4,7 +4,6 @@ from typing_extensions import Annotated -from litestar._openapi.schema_generation.utils import get_formatted_examples from litestar.contrib.pydantic.utils import ( create_field_definitions_for_computed_fields, is_pydantic_2_model, @@ -15,7 +14,7 @@ pydantic_unwrap_and_get_origin, ) from litestar.exceptions import MissingDependencyException -from litestar.openapi.spec import Example, OpenAPIFormat, OpenAPIType, Schema +from litestar.openapi.spec import OpenAPIFormat, OpenAPIType, Schema from litestar.plugins import OpenAPISchemaPlugin from litestar.types import Empty from litestar.typing import FieldDefinition @@ -314,11 +313,5 @@ def for_pydantic_model(cls, field_definition: FieldDefinition, schema_creator: S required=sorted(f.name for f in property_fields.values() if f.is_required), property_fields=property_fields, title=title, - examples=( - None - if example is None - else get_formatted_examples( - field_definition, [Example(description=f"Example {field_definition.name} value", value=example)] - ) - ), + examples=None if example is None else [example], ) diff --git a/litestar/openapi/spec/schema.py b/litestar/openapi/spec/schema.py index 41c122aa1e..7c626a2be8 100644 --- a/litestar/openapi/spec/schema.py +++ b/litestar/openapi/spec/schema.py @@ -9,7 +9,6 @@ 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 @@ -610,7 +609,7 @@ class Schema(BaseSchemaObject): Omitting these keywords has the same behavior as values of false. """ - examples: Mapping[str, Example] | None = None + examples: list[Any] | None = None """The value of this must be an array containing the example values directly or a mapping of string to an ``Example`` instance. diff --git a/litestar/typing.py b/litestar/typing.py index 1048296592..7141246711 100644 --- a/litestar/typing.py +++ b/litestar/typing.py @@ -4,13 +4,24 @@ from copy import deepcopy from dataclasses import dataclass, is_dataclass, replace from inspect import Parameter, Signature -from typing import Any, AnyStr, Callable, Collection, ForwardRef, Literal, Mapping, Protocol, Sequence, TypeVar, cast +from typing import ( + Any, + AnyStr, + Callable, + Collection, + ForwardRef, + Literal, + Mapping, + Protocol, + Sequence, + TypeVar, + cast, +) from msgspec import UnsetType from typing_extensions import NotRequired, Required, Self, get_args, get_origin, get_type_hints, is_typeddict from litestar.exceptions import ImproperlyConfiguredException -from litestar.openapi.spec import Example from litestar.params import BodyKwarg, DependencyKwarg, KwargDefinition, ParameterKwarg from litestar.types import Empty from litestar.types.builtin_types import NoneType, UnionTypes @@ -83,10 +94,11 @@ def _parse_metadata(value: Any, is_sequence_container: bool, extra: dict[str, An **cast("dict[str, Any]", extra or getattr(value, "extra", None) or {}), **(getattr(value, "json_schema_extra", None) or {}), } + example_list: list[Any] | None if example := extra.pop("example", None): - example_list = [Example(value=example)] + example_list = [example] elif examples := getattr(value, "examples", None): - example_list = [Example(value=example) for example in cast("list[str]", examples)] + example_list = examples else: example_list = None diff --git a/tests/unit/test_contrib/test_pydantic/test_openapi.py b/tests/unit/test_contrib/test_pydantic/test_openapi.py index 124362062e..aa84def09b 100644 --- a/tests/unit/test_contrib/test_pydantic/test_openapi.py +++ b/tests/unit/test_contrib/test_pydantic/test_openapi.py @@ -17,7 +17,7 @@ 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 Example, Reference, Schema +from litestar.openapi.spec import Reference, Schema from litestar.openapi.spec.enums import OpenAPIFormat, OpenAPIType from litestar.params import KwargDefinition from litestar.status_codes import HTTP_200_OK @@ -393,7 +393,7 @@ async def example_route() -> Lookup: assert response.status_code == HTTP_200_OK assert response.json()["components"]["schemas"]["test_schema_generation_v1.Lookup"]["properties"]["id"] == { "description": "A unique identifier", - "examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}}, + "examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd"], "maxLength": 16, "minLength": 12, "type": "string", @@ -430,7 +430,7 @@ async def example_route() -> Lookup: assert response.status_code == HTTP_200_OK assert response.json()["components"]["schemas"]["test_schema_generation_v2.Lookup"]["properties"]["id"] == { "description": "A unique identifier", - "examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}}, + "examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd"], "maxLength": 16, "minLength": 12, "type": "string", @@ -530,7 +530,7 @@ class Model(pydantic_v1.BaseModel): assert isinstance(value, Schema) assert value.description == "description" assert value.title == "title" - assert value.examples == {"value-example-1": Example(value="example")} + assert value.examples == ["example"] def test_create_schema_for_field_v2() -> None: @@ -550,7 +550,7 @@ class Model(pydantic_v2.BaseModel): assert isinstance(value, Schema) assert value.description == "description" assert value.title == "title" - assert value.examples == {"value-example-1": Example(value="example")} + assert value.examples == ["example"] @pytest.mark.parametrize("with_future_annotations", [True, False]) diff --git a/tests/unit/test_openapi/test_integration.py b/tests/unit/test_openapi/test_integration.py index 30b0a7a1bc..e99420ba4a 100644 --- a/tests/unit/test_openapi/test_integration.py +++ b/tests/unit/test_openapi/test_integration.py @@ -177,7 +177,7 @@ async def example_route() -> Lookup: "id" ] == { "description": "A unique identifier", - "examples": {"id-example-1": {"value": "e4eaaaf2-d142-11e1-b3e4-080027620cdd"}}, + "examples": ["e4eaaaf2-d142-11e1-b3e4-080027620cdd"], "maxLength": 16, "minLength": 12, "type": "string", diff --git a/tests/unit/test_openapi/test_parameters.py b/tests/unit/test_openapi/test_parameters.py index 8940c3d0a3..37a9dec108 100644 --- a/tests/unit/test_openapi/test_parameters.py +++ b/tests/unit/test_openapi/test_parameters.py @@ -71,8 +71,8 @@ def test_create_parameters(person_controller: Type[Controller]) -> None: assert page_size.schema.type == OpenAPIType.INTEGER assert page_size.required assert page_size.description == "Page Size Description" - assert page_size.schema.examples - assert next(iter(page_size.schema.examples.values())).value == 1 + assert page_size.examples + assert page_size.schema.examples == [1] assert name.param_in == ParamType.QUERY assert name.name == "name" @@ -107,19 +107,19 @@ def test_create_parameters(person_controller: Type[Controller]) -> None: Schema( type=OpenAPIType.STRING, enum=["M", "F", "O", "A"], - examples={"gender-example-1": Example(description="Example value", value="M")}, + examples=["M"], ), Schema( type=OpenAPIType.ARRAY, items=Schema( type=OpenAPIType.STRING, enum=["M", "F", "O", "A"], - examples={"gender-example-1": Example(description="Example value", value="F")}, + examples=["F"], ), - examples={"list-example-1": Example(description="Example value", value=["A"])}, + examples=[["A"]], ), ], - examples={"gender-example-1": Example(value="M"), "gender-example-2": Example(value=["M", "O"])}, + examples=["M", ["M", "O"]], ) assert not gender.required diff --git a/tests/unit/test_openapi/test_schema.py b/tests/unit/test_openapi/test_schema.py index 1b05ade837..bafd94fd0e 100644 --- a/tests/unit/test_openapi/test_schema.py +++ b/tests/unit/test_openapi/test_schema.py @@ -82,7 +82,7 @@ def test_process_schema_result() -> None: assert kwarg_definition.examples for signature_key, schema_key in KWARG_DEFINITION_ATTRIBUTE_TO_OPENAPI_PROPERTY_MAP.items(): if schema_key == "examples": - assert schema.examples == {"str-example-1": kwarg_definition.examples[0]} + assert schema.examples == [kwarg_definition.examples[0].value] else: assert getattr(schema, schema_key) == getattr(kwarg_definition, signature_key) @@ -289,7 +289,7 @@ class Lookup(msgspec.Struct): schema = get_schema_for_field_definition(FieldDefinition.from_kwarg(name="Lookup", annotation=Lookup)) assert schema.properties["id"].type == OpenAPIType.STRING # type: ignore[index, union-attr] - assert schema.properties["id"].examples == {"id-example-1": Example(value="example")} # type: ignore[index, union-attr] + assert schema.properties["id"].examples == ["example"] # type: ignore[index, union-attr] assert schema.properties["id"].description == "description" # type: ignore[index] assert schema.properties["id"].title == "title" # type: ignore[index, union-attr] assert schema.properties["id"].max_length == 16 # type: ignore[index, union-attr]