Skip to content

Commit e352224

Browse files
declaresubclaude
andcommitted
fix(openapi): handle recursive PEP 695 type aliases in schema generation
`SchemaCreator.for_type_alias_type` unwrapped a `type` alias's `__value__` and recursed with no cycle guard, so a self-referential alias such as `type JSON = None | bool | str | list[JSON] | dict[str, JSON]` blew the stack with a RecursionError when building the OpenAPI document. Litestar already guards self-referential *models* via a registered `$ref`, but that guard was never extended to the type-alias path added in #3715. A self-referential alias is now registered as a named component and its recursive occurrences resolve to a `$ref`, mirroring how recursive models are handled; non-recursive aliases are still inlined. Mutually recursive aliases terminate as well: the cycle entry point becomes the component, and aliases reached while expanding it are inlined. When an alias's value is itself a single component (e.g. `type Node = Container` where `Container` refers back to `Node`), the component defers to it via `allOf`. `_get_normalized_schema_key` now keys `TypeAliasType`, which exposes `__name__` but no `__qualname__`. Because schemas for recursive aliases now generate, example generation becomes reachable for them and would itself recurse without bound (the example factory guards recursive models but not aliases). Example generation is now skipped for any annotation that is, or contains, a self-referential alias; non-recursive aliases reused within a single annotation are unaffected. Fixes #4843 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c05c336 commit e352224

4 files changed

Lines changed: 250 additions & 10 deletions

File tree

litestar/_openapi/datastructures.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@ def _get_normalized_schema_key(field_definition: FieldDefinition) -> tuple[str,
6464
return (override,)
6565

6666
annotation = field_definition.annotation
67-
module = getattr(annotation, "__module__", "")
68-
name = str(annotation)[len(module) + 1 :] if isinstance(annotation, _GenericAlias) else annotation.__qualname__
67+
module = getattr(annotation, "__module__", None) or ""
68+
if isinstance(annotation, _GenericAlias):
69+
name = str(annotation)[len(module) + 1 :]
70+
else:
71+
# ``TypeAliasType`` (PEP 695 / ``typing_extensions``) exposes ``__name__`` but no ``__qualname__``.
72+
name = getattr(annotation, "__qualname__", None) or annotation.__name__
6973
name = name.replace(".<locals>.", ".")
7074
return *module.split("."), re.sub(INVALID_KEY_CHARACTER_PATTERN, "_", name)
7175

litestar/_openapi/schema_generation/examples.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from litestar.openapi.spec import Example
1717
from litestar.plugins.pydantic.utils import is_pydantic_model_instance
1818
from litestar.types import Empty
19+
from litestar.typing import TypeAliasTypes
1920

2021
if TYPE_CHECKING:
2122
from litestar.typing import FieldDefinition
@@ -58,6 +59,51 @@ def _normalize_example_value(value: Any) -> Any:
5859
return value
5960

6061

62+
def _type_alias_is_recursive(alias: Any) -> bool:
63+
"""Return whether expanding ``alias`` would recurse, i.e. the alias is reachable from its own value.
64+
65+
This covers both directly self-referential aliases (``type JSON = ... | list[JSON]``) and mutually
66+
recursive ones (``type A = B`` / ``type B = A``), where ``alias`` is reached again via another alias.
67+
"""
68+
target = id(alias)
69+
seen: set[int] = set()
70+
stack = [alias.__value__]
71+
while stack:
72+
obj = stack.pop()
73+
if id(obj) in seen:
74+
continue
75+
seen.add(id(obj))
76+
if isinstance(obj, TypeAliasTypes):
77+
if id(obj) == target:
78+
return True
79+
stack.append(obj.__value__)
80+
stack.extend(get_args(obj))
81+
return False
82+
83+
84+
def _contains_recursive_type_alias(annotation: Any) -> bool:
85+
"""Return whether ``annotation`` is, or contains, a self-referential PEP 695 ``type`` alias.
86+
87+
The example factory does not guard against recursive ``type`` aliases (it does for recursive models) and
88+
would recurse without bound while building an example value for one, e.g. ``dict[str, JSON]`` where
89+
``type JSON = ... | dict[str, JSON]``. This detects such aliases so example generation can be skipped.
90+
A non-recursive alias reused several times in one annotation (``tuple[Name, Name]``) is *not* flagged.
91+
"""
92+
seen: set[int] = set()
93+
stack = [annotation]
94+
while stack:
95+
obj = stack.pop()
96+
if id(obj) in seen:
97+
continue
98+
seen.add(id(obj))
99+
if isinstance(obj, TypeAliasTypes):
100+
if _type_alias_is_recursive(obj):
101+
return True
102+
stack.append(obj.__value__)
103+
stack.extend(get_args(obj))
104+
return False
105+
106+
61107
def _create_field_meta(field: FieldDefinition) -> FieldMeta:
62108
return FieldMeta.from_type(
63109
annotation=field.annotation,
@@ -75,6 +121,9 @@ def create_examples_for_field(field: FieldDefinition) -> list[Example]:
75121
Returns:
76122
A list including a single example.
77123
"""
124+
if _contains_recursive_type_alias(field.annotation):
125+
# the example factory would recurse without bound on a self-referential ``type`` alias
126+
return []
78127
try:
79128
field_meta = _create_field_meta(replace(field, annotation=_normalize_example_value(field.annotation)))
80129
value = ExampleFactory.get_field_value(field_meta)

litestar/_openapi/schema_generation/schema.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from collections import OrderedDict, defaultdict, deque
66
from collections.abc import Hashable, Iterable, Mapping, MutableMapping, MutableSequence, Sequence
77
from copy import copy
8+
from dataclasses import fields
89
from datetime import date, datetime, time, timedelta
910
from decimal import Decimal
1011
from enum import Enum
@@ -219,7 +220,14 @@ def create_schema_for_annotation(annotation: Any) -> Schema:
219220

220221

221222
class SchemaCreator:
222-
__slots__ = ("generate_examples", "plugins", "prefer_alias", "schema_registry")
223+
__slots__ = (
224+
"_in_progress_alias_ids",
225+
"_recursive_alias_ids",
226+
"generate_examples",
227+
"plugins",
228+
"prefer_alias",
229+
"schema_registry",
230+
)
223231

224232
def __init__(
225233
self,
@@ -240,6 +248,10 @@ def __init__(
240248
self.plugins = plugins if plugins is not None else []
241249
self.prefer_alias = prefer_alias
242250
self.schema_registry = schema_registry or SchemaRegistry()
251+
# ids of ``TypeAliasType`` annotations currently being expanded, used to detect self-references
252+
self._in_progress_alias_ids: set[int] = set()
253+
# ids of ``TypeAliasType`` annotations found to be self-referential during expansion
254+
self._recursive_alias_ids: set[int] = set()
243255

244256
@classmethod
245257
def from_openapi_context(cls, context: OpenAPIContext, prefer_alias: bool = True, **kwargs: Any) -> Self:
@@ -343,14 +355,58 @@ def for_new_type(self, field_definition: FieldDefinition) -> Schema | Reference:
343355
)
344356

345357
def for_type_alias_type(self, field_definition: FieldDefinition) -> Schema | Reference:
346-
return self.for_field_definition(
347-
FieldDefinition.from_kwarg(
348-
annotation=field_definition.annotation.__value__,
349-
name=field_definition.name,
350-
default=field_definition.default,
351-
kwarg_definition=field_definition.kwarg_definition,
358+
"""Create a schema for a PEP 695 ``type`` alias (``TypeAliasType``).
359+
360+
The alias's ``__value__`` is unwrapped and a schema is created for it. Self-referential aliases,
361+
e.g. ``type JSON = str | int | list[JSON] | dict[str, JSON]``, are registered as named components so
362+
that recursive occurrences resolve to a ``$ref`` -- mirroring how self-referential models are handled
363+
(see :meth:`create_component_schema`) and breaking what would otherwise be unbounded recursion.
364+
Non-recursive aliases are inlined as before.
365+
366+
Note:
367+
Only the alias that is the entry point of a cycle becomes a component; any *mutually* recursive
368+
aliases reached while expanding it are inlined. The result is always valid and finite, but for
369+
mutually recursive aliases used at several independent entry points the emitted components are not
370+
minimal (each inlines a copy of the other's value).
371+
"""
372+
alias_id = id(field_definition.annotation)
373+
374+
if alias_id in self._in_progress_alias_ids:
375+
# We are already expanding this alias higher up the call stack: this is a recursive reference.
376+
# Register it as a component (if not already) and return a reference to break the cycle.
377+
self._recursive_alias_ids.add(alias_id)
378+
self.schema_registry.get_schema_for_field_definition(field_definition)
379+
return self.schema_registry.get_reference_for_field_definition(field_definition) # type: ignore[return-value]
380+
381+
self._in_progress_alias_ids.add(alias_id)
382+
try:
383+
schema = self.for_field_definition(
384+
FieldDefinition.from_kwarg(
385+
annotation=field_definition.annotation.__value__,
386+
name=field_definition.name,
387+
default=field_definition.default,
388+
kwarg_definition=field_definition.kwarg_definition,
389+
)
352390
)
353-
)
391+
finally:
392+
self._in_progress_alias_ids.discard(alias_id)
393+
394+
if alias_id not in self._recursive_alias_ids:
395+
# No self-reference was encountered while expanding the value: inline the schema as before.
396+
return schema
397+
398+
self._recursive_alias_ids.discard(alias_id)
399+
# The alias was registered as a component while expanding its value (the recursive references above
400+
# already point at it). Populate that component with the resolved schema and return a reference to it.
401+
component = self.schema_registry.get_schema_for_field_definition(field_definition)
402+
if isinstance(schema, Schema):
403+
for schema_field in fields(Schema):
404+
setattr(component, schema_field.name, getattr(schema, schema_field.name))
405+
else:
406+
# the value resolved to a reference (e.g. ``type Alias = SomeModel``); defer to it
407+
component.all_of = [schema]
408+
component.title = field_definition.annotation.__name__
409+
return self.schema_registry.get_reference_for_field_definition(field_definition) or component
354410

355411
@staticmethod
356412
def for_upload_file(field_definition: FieldDefinition) -> Schema:

tests/unit/test_openapi/test_schema.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from litestar.di import NamedDependency, Provide
3535
from litestar.enums import ParamType
3636
from litestar.exceptions import ImproperlyConfiguredException
37+
from litestar.openapi import OpenAPIConfig
3738
from litestar.openapi.spec import ExternalDocumentation, OpenAPIType, Reference
3839
from litestar.openapi.spec.example import Example
3940
from litestar.openapi.spec.parameter import Parameter as OpenAPIParameter
@@ -837,6 +838,136 @@ def handler(query_param: Annotated[annotation, QueryParameter(description="foo")
837838
assert param.description == "foo"
838839

839840

841+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12")
842+
def test_recursive_type_alias_type() -> None:
843+
# https://github.com/litestar-org/litestar/issues/4843
844+
# A self-referential PEP 695 ``type`` alias must not blow the stack when generating the OpenAPI schema.
845+
ctx: dict[str, Any] = {"__name__": __name__}
846+
exec("type JSON = None | bool | str | float | int | list[JSON] | dict[str, JSON]", ctx, None)
847+
annotation = ctx["JSON"]
848+
849+
@get("/")
850+
def handler() -> annotation: # type: ignore[valid-type]
851+
return {}
852+
853+
app = Litestar([handler])
854+
schema = app.openapi_schema.to_schema()
855+
856+
# the alias is registered as a named component and the response references it
857+
assert "JSON" in schema["components"]["schemas"]
858+
response_schema = schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
859+
assert response_schema == {"$ref": "#/components/schemas/JSON"}
860+
861+
# the recursive occurrences inside the component resolve to a ``$ref`` back to the alias, breaking the cycle
862+
component = schema["components"]["schemas"]["JSON"]
863+
one_of = component["oneOf"]
864+
array_schema = next(s for s in one_of if s.get("type") == "array")
865+
object_schema = next(s for s in one_of if s.get("type") == "object")
866+
assert array_schema["items"] == {"$ref": "#/components/schemas/JSON"}
867+
assert object_schema["additionalProperties"] == {"$ref": "#/components/schemas/JSON"}
868+
869+
870+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12")
871+
def test_mutually_recursive_type_alias_types() -> None:
872+
# Mutually recursive aliases must terminate as well, not just directly self-referential ones.
873+
ctx: dict[str, Any] = {"__name__": __name__}
874+
exec("type A = str | list[B]\ntype B = int | dict[str, A]", ctx, None)
875+
annotation = ctx["A"]
876+
877+
@get("/")
878+
def handler() -> annotation: # type: ignore[valid-type]
879+
return ""
880+
881+
app = Litestar([handler])
882+
# smoke test: this must not raise ``RecursionError``
883+
schema = app.openapi_schema.to_schema()
884+
response_schema = schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
885+
assert response_schema == {"$ref": "#/components/schemas/A"}
886+
887+
888+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12")
889+
def test_type_alias_type_to_recursive_model() -> None:
890+
# An alias whose value is a single component (here a dataclass) that recurses back into the alias: the alias's
891+
# value resolves to a ``Reference`` rather than a ``Schema``, so the component defers to it via ``allOf``.
892+
# ``exec`` into the module globals so the dataclass's forward reference to the alias can be resolved.
893+
exec(
894+
"@dataclass\nclass _AliasContainer:\n children: 'list[_AliasNode]'\ntype _AliasNode = _AliasContainer",
895+
globals(),
896+
)
897+
annotation = globals()["_AliasNode"]
898+
899+
@get("/")
900+
def handler() -> annotation: # type: ignore[valid-type]
901+
...
902+
903+
app = Litestar([handler])
904+
schema = app.openapi_schema.to_schema()
905+
schemas = schema["components"]["schemas"]
906+
assert schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] == {
907+
"$ref": "#/components/schemas/_AliasNode"
908+
}
909+
# the ``_AliasNode`` component defers to the ``_AliasContainer`` component
910+
assert schemas["_AliasNode"]["allOf"] == [{"$ref": "#/components/schemas/_AliasContainer"}]
911+
# and ``_AliasContainer`` references ``_AliasNode`` back, completing the cycle without recursing infinitely
912+
assert schemas["_AliasContainer"]["properties"]["children"]["items"] == {"$ref": "#/components/schemas/_AliasNode"}
913+
914+
915+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12")
916+
def test_recursive_type_alias_type_with_example_generation() -> None:
917+
# https://github.com/litestar-org/litestar/issues/4843
918+
# The example factory does not guard against recursive ``type`` aliases, so example generation must be
919+
# skipped for them -- otherwise it recurses without bound (here ``dict[str, JSON]`` is the dangerous shape,
920+
# processed after the alias has finished expanding).
921+
ctx: dict[str, Any] = {"__name__": __name__}
922+
exec("type JSON = None | bool | str | float | int | list[JSON] | dict[str, JSON]", ctx, None)
923+
annotation = ctx["JSON"]
924+
925+
@get("/")
926+
def handler() -> dict[str, annotation]: # type: ignore[valid-type]
927+
return {}
928+
929+
app = Litestar([handler], openapi_config=OpenAPIConfig(title="t", version="1", create_examples=True))
930+
# must terminate (no ``RecursionError`` / hang) -- a non-recursive annotation containing the alias
931+
schema = app.openapi_schema.to_schema()
932+
component = schema["components"]["schemas"]["JSON"]
933+
# example generation was skipped for the self-referential shapes, so no value was fabricated for them
934+
assert component.get("examples", []) == []
935+
array_member = next(s for s in component["oneOf"] if s.get("type") == "array")
936+
assert array_member.get("examples", []) == []
937+
938+
939+
@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12")
940+
def test_contains_recursive_type_alias_detection() -> None:
941+
from litestar._openapi.schema_generation.examples import _contains_recursive_type_alias
942+
943+
# the composite annotations are built inside ``exec`` so they are not analysed as type expressions
944+
ctx: dict[str, Any] = {"__name__": __name__}
945+
exec(
946+
"type Name = str\n"
947+
"type JSON = None | str | list[JSON]\n"
948+
"type Wrapper = list[JSON]\n"
949+
"type A = str | list[B]\n"
950+
"type B = int | dict[str, A]\n"
951+
"dict_str_json = dict[str, JSON]\n"
952+
"tuple_name_name = tuple[Name, Name]\n"
953+
"dict_name_name = dict[Name, Name]",
954+
ctx,
955+
None,
956+
)
957+
958+
# directly recursive, mutually recursive, and aliases that *contain* a recursive one
959+
assert _contains_recursive_type_alias(ctx["JSON"]) is True
960+
assert _contains_recursive_type_alias(ctx["dict_str_json"]) is True
961+
assert _contains_recursive_type_alias(ctx["Wrapper"]) is True
962+
assert _contains_recursive_type_alias(ctx["A"]) is True
963+
assert _contains_recursive_type_alias(ctx["B"]) is True
964+
# a non-recursive alias is not flagged -- even when reused several times in one annotation
965+
assert _contains_recursive_type_alias(ctx["Name"]) is False
966+
assert _contains_recursive_type_alias(ctx["tuple_name_name"]) is False
967+
assert _contains_recursive_type_alias(ctx["dict_name_name"]) is False
968+
assert _contains_recursive_type_alias(int) is False
969+
970+
840971
def test_decimal_schema_type() -> None:
841972
from litestar._openapi.schema_generation.schema import create_schema_for_annotation
842973

0 commit comments

Comments
 (0)