Skip to content

Commit 57a1ef6

Browse files
committed
Add support for self-references in JSON Schema
1 parent 822dfec commit 57a1ef6

3 files changed

Lines changed: 137 additions & 44 deletions

File tree

mashumaro/jsonschema/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,5 @@ class Context:
212212
plugins: Sequence[BasePlugin] = ()
213213
# PEP 695 TypeAliasType recursion guard
214214
_building_type_aliases: set[int] = field(default_factory=set, repr=False)
215+
# Dataclass recursion guard (e.g. typing.Self, direct or mutual references)
216+
_building_dataclasses: set[type] = field(default_factory=set, repr=False)

mashumaro/jsonschema/schema.py

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
is_not_required,
4242
is_readonly,
4343
is_required,
44+
is_self,
4445
is_special_typing_primitive,
4546
is_type_alias_type,
4647
is_type_var,
@@ -364,52 +365,69 @@ def override_with_any(reason: Any) -> None:
364365

365366
@register
366367
def on_dataclass(instance: Instance, ctx: Context) -> Optional[JSONSchema]:
367-
# TODO: Self references might not work
368368
if is_dataclass(instance.origin_type):
369+
# When dataclasses reference themselves (typing.Self) or each other,
370+
# we must break infinite recursion by forcing $ref/$defs.
371+
origin = instance.origin_type
372+
369373
if ctx.all_refs:
370-
title = clean_id(type_name(instance.type, short=True))
371-
title = title.strip("_")
374+
def_key = clean_id(type_name(instance.type, short=True)).strip("_")
372375
else:
373-
title = instance.origin_type.__name__
374-
jsonschema_config = instance.get_self_config().json_schema
375-
schema = JSONObjectSchema(
376-
title=title,
377-
additionalProperties=jsonschema_config.get(
378-
"additionalProperties", False
379-
),
380-
)
381-
properties: dict[str, JSONSchema] = {}
382-
required = []
383-
field_schema_overrides = jsonschema_config.get("properties", {})
384-
for f_name, f_type, has_default, f_default in instance.fields():
385-
override = field_schema_overrides.get(f_name)
386-
f_instance = instance.derive(type=f_type, name=f_name)
387-
if override:
388-
f_schema = JSONSchema.from_dict(override)
376+
def_key = origin.__name__
377+
378+
ref_prefix = ctx.ref_prefix or ctx.dialect.definitions_root_pointer
379+
380+
if origin in ctx._building_dataclasses:
381+
# Ensure placeholder exists so the final schema can fill it in.
382+
ctx.definitions.setdefault(def_key, EmptyJSONSchema())
383+
return JSONSchema(reference=f"{ref_prefix}/{def_key}")
384+
385+
ctx._building_dataclasses.add(origin)
386+
try:
387+
# If a placeholder exists (recursion), we'll populate it later
388+
jsonschema_config = instance.get_self_config().json_schema
389+
schema = JSONObjectSchema(
390+
title=def_key,
391+
additionalProperties=jsonschema_config.get(
392+
"additionalProperties", False
393+
),
394+
)
395+
properties: dict[str, JSONSchema] = {}
396+
required = []
397+
field_schema_overrides = jsonschema_config.get("properties", {})
398+
for f_name, f_type, has_default, f_default in instance.fields():
399+
override = field_schema_overrides.get(f_name)
400+
f_instance = instance.derive(type=f_type, name=f_name)
401+
if override:
402+
f_schema = JSONSchema.from_dict(override)
403+
else:
404+
f_schema = get_schema(f_instance, ctx)
405+
if f_instance.alias:
406+
f_name = f_instance.alias
407+
if f_default is not MISSING:
408+
f_schema.default = f_default
409+
description = f_instance.metadata.get("description")
410+
if description:
411+
f_schema.description = description
412+
413+
if not has_default:
414+
required.append(f_name)
415+
416+
properties[f_name] = f_schema
417+
if properties:
418+
schema.properties = properties
419+
if required:
420+
schema.required = required
421+
422+
# If recursion was detected, we need $defs/$ref regardless
423+
existing = ctx.definitions.get(def_key)
424+
if ctx.all_refs or isinstance(existing, EmptyJSONSchema):
425+
ctx.definitions[def_key] = schema
426+
return JSONSchema(reference=f"{ref_prefix}/{def_key}")
389427
else:
390-
f_schema = get_schema(f_instance, ctx)
391-
if f_instance.alias:
392-
f_name = f_instance.alias
393-
if f_default is not MISSING:
394-
f_schema.default = f_default
395-
description = f_instance.metadata.get("description")
396-
if description:
397-
f_schema.description = description
398-
399-
if not has_default:
400-
required.append(f_name)
401-
402-
properties[f_name] = f_schema
403-
if properties:
404-
schema.properties = properties
405-
if required:
406-
schema.required = required
407-
if ctx.all_refs:
408-
ctx.definitions[title] = schema
409-
ref_prefix = ctx.ref_prefix or ctx.dialect.definitions_root_pointer
410-
return JSONSchema(reference=f"{ref_prefix}/{title}")
411-
else:
412-
return schema
428+
return schema
429+
finally:
430+
ctx._building_dataclasses.discard(origin)
413431

414432

415433
@register
@@ -466,8 +484,16 @@ def on_special_typing_primitive(
466484
)
467485
elif is_literal(instance.type):
468486
return on_literal(instance, ctx)
469-
# elif is_self(instance.type):
470-
# raise NotImplementedError
487+
elif is_self(instance.type):
488+
# typing.Self / typing_extensions.Self is only meaningful inside
489+
# a class body. In dataclasses, Instance.owner_class points to the
490+
# dataclass that defines the field.
491+
owner = instance.owner_class
492+
if owner is None: # pragma: no cover
493+
raise NotImplementedError(
494+
"typing.Self is supported only for dataclass fields"
495+
)
496+
return get_schema(instance.derive(type=owner), ctx)
471497
elif is_required(instance.type) or is_not_required(instance.type):
472498
return get_schema(instance.derive(type=args[0]), ctx)
473499
elif is_unpack(instance.type):

tests/test_jsonschema/test_self.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Optional
5+
6+
from typing_extensions import Self
7+
8+
from mashumaro.jsonschema import build_json_schema
9+
from mashumaro.jsonschema.models import JSONArraySchema, JSONObjectSchema
10+
11+
12+
def test_jsonschema_supports_self_with_refs() -> None:
13+
@dataclass
14+
class Node:
15+
child: Optional[Self] = None
16+
items: list[Self] = None # type: ignore[assignment]
17+
18+
schema = build_json_schema(Node, all_refs=True)
19+
20+
# Top-level schema should be a $ref into $defs
21+
assert schema.reference is not None
22+
assert schema.reference.startswith("#/$defs/")
23+
24+
def_key = schema.reference.split("#/$defs/", 1)[1]
25+
26+
# And the referenced definition should exist in $defs
27+
assert schema.definitions is not None
28+
assert def_key in schema.definitions
29+
30+
node_def = schema.definitions[def_key]
31+
assert isinstance(node_def, JSONObjectSchema)
32+
33+
# Optional[Self] => anyOf [$ref-to-self, null]
34+
child_schema = node_def.properties["child"]
35+
assert child_schema.anyOf is not None
36+
assert any(s.reference == schema.reference for s in child_schema.anyOf)
37+
assert any(
38+
getattr(s.type, "value", None) == "null" for s in child_schema.anyOf
39+
)
40+
41+
# list[Self] => array items $ref-to-self
42+
items_schema = node_def.properties["items"]
43+
assert isinstance(items_schema, JSONArraySchema)
44+
assert items_schema.items is not None
45+
assert items_schema.items.reference == schema.reference
46+
47+
48+
def test_jsonschema_self_forces_refs_when_recursive_even_without_all_refs() -> (
49+
None
50+
):
51+
@dataclass
52+
class Node:
53+
child: Self | None = None
54+
55+
schema = build_json_schema(Node)
56+
57+
# For recursive dataclasses we expect $defs/$ref even if all_refs=False
58+
assert schema.reference == "#/$defs/Node"
59+
assert schema.definitions is not None
60+
assert "Node" in schema.definitions
61+
62+
node_def = schema.definitions["Node"].to_dict()
63+
assert node_def.get("title") == "Node"
64+
assert node_def.get("type") == "object"
65+
assert node_def.get("properties", {}).get("child", {}).get("anyOf")

0 commit comments

Comments
 (0)