Skip to content

Commit

Permalink
fix: JSON schema examples were OpenAPI formatted
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
tuukkamustonen committed Mar 18, 2024
1 parent d292aac commit d8606b5
Show file tree
Hide file tree
Showing 10 changed files with 49 additions and 36 deletions.
4 changes: 1 addition & 3 deletions litestar/_openapi/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions litestar/_openapi/schema_generation/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion litestar/_openapi/schema_generation/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = (
Expand Down Expand Up @@ -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
11 changes: 2 additions & 9 deletions litestar/contrib/pydantic/pydantic_schema_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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],
)
3 changes: 1 addition & 2 deletions litestar/openapi/spec/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions litestar/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions tests/unit/test_contrib/test_pydantic/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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])
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 @@ -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",
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 @@ -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"
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={"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

Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_openapi/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand Down

0 comments on commit d8606b5

Please sign in to comment.