-
-
Notifications
You must be signed in to change notification settings - Fork 531
Bug: SchemaCreator.for_optional_field drops Meta() constraints and examples for Annotated optional fields #4650
Description
Description
Summary
When a field is typed as Annotated[T, Meta(...)] | None, all Meta constraints
(ge, le, gt, lt, pattern, min_length, max_length, examples, etc.) are
silently dropped from the generated OpenAPI schema. The oneOf non-null branch is
emitted as a bare {"type": "integer"} with no validation metadata. Fields typed the
same way but without | None generate correctly.
Root cause
SchemaCreator.for_optional_field creates an inner FieldDefinition to generate the
non-null branch of the oneOf wrapper, but does not forward kwarg_definition in that
call. Since kwarg_definition is the carrier for the Annotated metadata, the inner
schema is generated without it and all constraints are lost.
Minimal reproduction
# pip install litestar msgspec
from typing import Annotated
from msgspec import Meta, Struct
from litestar import Litestar, post
import json
IntWithExample = Annotated[int, Meta(ge=1, examples=[42])]
class SearchRequest(Struct, kw_only=True):
required_field: IntWithExample # not optional
optional_field: IntWithExample | None = None
@post("/search")
async def search(data: SearchRequest) -> None: ...
app = Litestar(route_handlers=[search])
props = app.openapi_schema.to_schema()["components"]["schemas"]["SearchRequest"]["properties"]
print(json.dumps(props, indent=2))Expected behaviour
{
"required_field": {
"type": "integer",
"minimum": 1.0,
"examples": [42]
},
"optional_field": {
"oneOf": [
{ "type": "integer", "minimum": 1.0, "examples": [42] },
{ "type": "null" }
]
}
}Actual behaviour
{
"required_field": {
"type": "integer",
"minimum": 1.0,
"examples": [42]
},
"optional_field": {
"oneOf": [
{ "type": "integer" },
{ "type": "null" }
]
}
}All constraints are absent from the oneOf integer branch. Every OpenAPI renderer
shows no validation metadata or pre-fill value for optional fields.
Impact
Any Annotated type alias with Meta(...) constraints used as an optional field loses
all validation metadata in the OpenAPI output. This affects schema correctness (missing
minimum, maximum, pattern, etc.) as well as developer experience in all renderers
(Swagger UI, Scalar, Redoc, Stoplight Elements), which cannot pre-fill example values.
Workaround
Monkeypatching SchemaCreator.for_optional_field to walk the original Python annotation
directly and copy examples[0] onto the outer schema after the fact. Functional but
fragile against internal refactors, and only recovers examples — the missing
validation constraints (minimum, pattern, etc.) remain absent from the schema.
Workaround
Monkeypatching SchemaCreator.for_optional_field to walk the original Python annotation directly and copy examples[0] onto the outer schema after the fact. Functional but fragile against internal refactors.
"""
Monkeypatches for Litestar's OpenAPI schema generation.
Import this module before creating the Litestar app.
"""
from types import UnionType
from typing import (
Annotated,
Union, # pyright: ignore[reportDeprecated]
get_args,
get_origin,
)
import msgspec
from litestar._openapi.schema_generation.schema import SchemaCreator
from litestar.openapi.spec import Schema
from litestar.typing import FieldDefinition
_original_for_optional_field = SchemaCreator.for_optional_field
def _extract_examples_from_annotation(annotation: object) -> list[object] | None:
"""Walk an annotation to find the first msgspec.Meta with examples.
Handles Annotated[T, Meta(examples=[...])] | None as well as plain
Annotated[T, Meta(examples=[...])] types.
"""
origin = get_origin(annotation)
if origin in [UnionType, Union]: # pyright: ignore[reportDeprecated]
for arg in get_args(annotation): # pyright: ignore[reportAny]
if arg is type(None):
continue
found = _extract_examples_from_annotation(arg) # pyright: ignore[reportAny]
if found is not None:
return found
if get_origin(annotation) is Annotated:
for meta in get_args(annotation)[1:]: # pyright: ignore[reportAny]
if isinstance(meta, msgspec.Meta) and meta.examples: # pyright: ignore[reportUnknownMemberType]
return list(meta.examples) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType]
return None
def _patched_for_optional_field(
self: SchemaCreator, field_definition: FieldDefinition
) -> Schema:
"""Set schema.example from msgspec.Meta(examples=[...]) on optional fields.
for_optional_field creates an inner FieldDefinition without propagating
kwarg_definition, so Meta(examples=[...]) is never applied to the inner
schema. We extract examples directly from the annotation instead.
"""
result = _original_for_optional_field(self, field_definition)
if result.one_of and result.example is None:
examples = _extract_examples_from_annotation(field_definition.annotation) # pyright: ignore[reportAny]
if examples:
result.example = examples[0]
return result
SchemaCreator.for_optional_field = _patched_for_optional_field # type: ignore[method-assign]URL to code causing the issue
No response
MCVE
# pip install litestar msgspec
from typing import Annotated
from msgspec import Meta, Struct
from litestar import Litestar, post
import json
IntWithExample = Annotated[int, Meta(ge=1, examples=[42])]
class SearchRequest(Struct, kw_only=True):
required_field: IntWithExample # not optional
optional_field: IntWithExample | None = None
@post("/search")
async def search(data: SearchRequest) -> None: ...
app = Litestar(route_handlers=[search])
props = app.openapi_schema.to_schema()["components"]["schemas"]["SearchRequest"]["properties"]
print(json.dumps(props, indent=2))Steps to reproduce
See above
Screenshots
No response
Logs
Litestar Version
Environment
| Litestar | 2.21.1 |
| Python | 3.12 |
| msgspec | 0.20.0 |
| OpenAPI spec version | 3.1.0 |
Platform
- Linux
- Mac
- Windows
- Other (Please specify in the description above)