Skip to content

Commit edb2b79

Browse files
authored
Merge pull request #303 from Fatal1ty/recursive-type-jsonschema
Add support for recursive types in JSON Schema
2 parents 420fa09 + 60362f3 commit edb2b79

3 files changed

Lines changed: 171 additions & 3 deletions

File tree

mashumaro/jsonschema/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,5 @@ class Context:
210210
all_refs: Optional[bool] = None
211211
ref_prefix: Optional[str] = None
212212
plugins: Sequence[BasePlugin] = ()
213+
# PEP 695 TypeAliasType recursion guard
214+
_building_type_aliases: set[int] = field(default_factory=set, repr=False)

mashumaro/jsonschema/schema.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,15 @@ class Config(config_cls): # type: ignore
317317
register = Registry.register
318318

319319

320+
def _type_alias_definition_name(alias_type: Any) -> str:
321+
"""Return a stable $defs key for PEP 695 TypeAliasType."""
322+
323+
name = getattr(alias_type, "__name__", None)
324+
if isinstance(name, str) and name:
325+
return name
326+
return clean_id(str(id(alias_type)))
327+
328+
320329
BASIC_TYPES = {str, int, float, bool}
321330

322331

@@ -474,7 +483,36 @@ def on_special_typing_primitive(
474483
if evaluated is not None:
475484
return get_schema(instance.derive(type=evaluated), ctx)
476485
elif is_type_alias_type(instance.type):
477-
return get_schema(instance.derive(type=instance.type.__value__), ctx)
486+
alias_type = instance.type
487+
def_name = _type_alias_definition_name(alias_type)
488+
alias_id = id(alias_type)
489+
ref_prefix = ctx.ref_prefix or ctx.dialect.definitions_root_pointer
490+
491+
# If we're already building this alias, it's recursion.
492+
# In that case, force using $ref/$defs.
493+
if alias_id in ctx._building_type_aliases:
494+
# The $defs placeholder may not exist yet (mutual recursion).
495+
ctx.definitions.setdefault(def_name, EmptyJSONSchema())
496+
return JSONSchema(reference=f"{ref_prefix}/{def_name}")
497+
498+
ctx._building_type_aliases.add(alias_id)
499+
try:
500+
value_schema = get_schema(
501+
instance.derive(type=alias_type.__value__), ctx
502+
)
503+
finally:
504+
ctx._building_type_aliases.discard(alias_id)
505+
506+
# If the alias is marked as recursive (direct or mutual),
507+
# store its definition in $defs and return a $ref.
508+
if def_name in ctx.definitions or ctx.all_refs:
509+
existing = ctx.definitions.get(def_name)
510+
if existing is None or isinstance(existing, EmptyJSONSchema):
511+
ctx.definitions[def_name] = value_schema
512+
return JSONSchema(reference=f"{ref_prefix}/{def_name}")
513+
514+
# Non-recursive alias: return the schema directly, without $defs.
515+
return value_schema
478516

479517

480518
@register
Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,137 @@
1-
type MyTypeAliasType = int | str
1+
from mashumaro.core.meta.types.common import clean_id
22
from mashumaro.jsonschema import build_json_schema
3+
from mashumaro.jsonschema.models import Context
4+
from mashumaro.jsonschema.schema import _type_alias_definition_name
5+
6+
type JSON = int | str | float | bool | None | list[JSON] | dict[str, JSON]
7+
type X = int | str
8+
type A = int | list[B]
9+
type B = str | list[A]
310

411

512
def test_type_alias_type_with_jsonschema():
6-
schema = build_json_schema(MyTypeAliasType)
13+
schema = build_json_schema(X)
714
assert schema.to_dict() == {
815
"anyOf": [{"type": "integer"}, {"type": "string"}]
916
}
17+
18+
19+
def test_jsonschema_for_recursive_union() -> None:
20+
schema = build_json_schema(JSON)
21+
assert schema.to_dict() == {
22+
"$ref": "#/$defs/JSON",
23+
"$defs": {
24+
"JSON": {
25+
"anyOf": [
26+
{"type": "integer"},
27+
{"type": "string"},
28+
{"type": "number"},
29+
{"type": "boolean"},
30+
{"type": "null"},
31+
{"type": "array", "items": {"$ref": "#/$defs/JSON"}},
32+
{
33+
"type": "object",
34+
"additionalProperties": {"$ref": "#/$defs/JSON"},
35+
"propertyNames": {"type": "string"},
36+
},
37+
]
38+
}
39+
},
40+
}
41+
42+
43+
def test_jsonschema_for_mutual_recursive_type_aliases_without_refs() -> None:
44+
schema = build_json_schema(A)
45+
assert schema.to_dict() == {
46+
"$ref": "#/$defs/A",
47+
"$defs": {
48+
"A": {
49+
"anyOf": [
50+
{"type": "integer"},
51+
{
52+
"type": "array",
53+
"items": {
54+
"anyOf": [
55+
{"type": "string"},
56+
{
57+
"type": "array",
58+
"items": {"$ref": "#/$defs/A"},
59+
},
60+
]
61+
},
62+
},
63+
]
64+
}
65+
},
66+
}
67+
68+
69+
def test_jsonschema_for_mutual_recursive_type_aliases_with_refs() -> None:
70+
schema = build_json_schema(A, all_refs=True)
71+
assert schema.to_dict() == {
72+
"$ref": "#/$defs/A",
73+
"$defs": {
74+
"A": {
75+
"anyOf": [
76+
{"type": "integer"},
77+
{"type": "array", "items": {"$ref": "#/$defs/B"}},
78+
]
79+
},
80+
"B": {
81+
"anyOf": [
82+
{"type": "string"},
83+
{"type": "array", "items": {"$ref": "#/$defs/A"}},
84+
]
85+
},
86+
},
87+
}
88+
89+
90+
def test_type_alias_non_recursive_inlines_when_all_refs_false() -> None:
91+
schema = build_json_schema(X, all_refs=False)
92+
assert schema.to_dict() == {
93+
"anyOf": [
94+
{"type": "integer"},
95+
{"type": "string"},
96+
]
97+
}
98+
99+
100+
def test_type_alias_non_recursive_uses_defs_when_all_refs_true() -> None:
101+
schema = build_json_schema(X, all_refs=True)
102+
assert schema.to_dict() == {
103+
"$ref": "#/$defs/X",
104+
"$defs": {
105+
"X": {
106+
"anyOf": [
107+
{"type": "integer"},
108+
{"type": "string"},
109+
]
110+
}
111+
},
112+
}
113+
114+
115+
def test_type_alias_placeholder_not_leaking_into_context_defs() -> None:
116+
# Ensure that for non-recursive aliases with all_refs=False we don't leave
117+
# unused entries in Context.definitions.
118+
ctx = Context()
119+
schema = build_json_schema(X, context=ctx, all_refs=False)
120+
assert schema.to_dict() == {
121+
"anyOf": [
122+
{"type": "integer"},
123+
{"type": "string"},
124+
]
125+
}
126+
assert ctx.definitions == {}
127+
128+
129+
def test_type_alias_definition_name_falls_back_to_clean_id_when_name_empty() -> (
130+
None
131+
):
132+
class NamelessAlias:
133+
__name__ = ""
134+
135+
alias = NamelessAlias()
136+
137+
assert _type_alias_definition_name(alias) == clean_id(str(id(alias)))

0 commit comments

Comments
 (0)