Skip to content

Bug: SchemaCreator.for_optional_field drops Meta() constraints and examples for Annotated optional fields #4650

@pavdwest

Description

@pavdwest

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bug 🐛This is something that is not working as expected

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions