Skip to content

Commit 3aaf118

Browse files
committed
Fix $ref handling in request-response mode for readOnly/writeOnly schemas
1 parent 60f7335 commit 3aaf118

5 files changed

Lines changed: 260 additions & 0 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,136 @@ def _should_generate_base_model(self, *, generates_separate_models: bool = False
979979
return True
980980
return not generates_separate_models
981981

982+
def _ref_schema_generates_variant(self, ref_path: str, suffix: str) -> bool:
983+
"""Check if a referenced schema will generate a specific variant (Request or Response).
984+
985+
For Request variant: schema must have readOnly fields AND at least one non-readOnly field.
986+
For Response variant: schema must have writeOnly fields AND at least one non-writeOnly field.
987+
"""
988+
try:
989+
ref_schema = self._load_ref_schema_object(ref_path)
990+
except Exception: # noqa: BLE001 # pragma: no cover
991+
return False
992+
993+
has_read_only = False
994+
has_write_only = False
995+
has_non_read_only = False
996+
has_non_write_only = False
997+
998+
for prop in (ref_schema.properties or {}).values():
999+
if not isinstance(prop, JsonSchemaObject):
1000+
continue
1001+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1002+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1003+
if is_read_only:
1004+
has_read_only = True
1005+
else:
1006+
has_non_read_only = True
1007+
if is_write_only:
1008+
has_write_only = True
1009+
else:
1010+
has_non_write_only = True
1011+
1012+
if suffix == "Request":
1013+
return has_read_only and has_non_read_only
1014+
if suffix == "Response":
1015+
return has_write_only and has_non_write_only
1016+
return False # pragma: no cover
1017+
1018+
def _ref_schema_has_model(self, ref_path: str) -> bool:
1019+
"""Check if a referenced schema will have a model (base or variant) generated.
1020+
1021+
Returns False if the schema has only readOnly or only writeOnly fields in request-response mode,
1022+
which would result in no model being generated at all.
1023+
"""
1024+
try:
1025+
ref_schema = self._load_ref_schema_object(ref_path)
1026+
except Exception: # noqa: BLE001 # pragma: no cover
1027+
return True
1028+
1029+
has_read_only = False
1030+
has_write_only = False
1031+
1032+
for prop in (ref_schema.properties or {}).values():
1033+
if not isinstance(prop, JsonSchemaObject):
1034+
continue
1035+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1036+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1037+
if is_read_only:
1038+
has_read_only = True
1039+
elif is_write_only:
1040+
has_write_only = True
1041+
else: # pragma: no cover
1042+
return True
1043+
1044+
if has_read_only and not has_write_only:
1045+
return False
1046+
return not (has_write_only and not has_read_only)
1047+
1048+
def _update_data_type_ref_for_variant(self, data_type: DataType, suffix: str) -> None:
1049+
"""Recursively update data type references to point to variant models."""
1050+
if data_type.reference:
1051+
ref_path = data_type.reference.path
1052+
if self._ref_schema_generates_variant(ref_path, suffix):
1053+
path_parts = ref_path.split("/")
1054+
base_name = path_parts[-1]
1055+
variant_name = f"{base_name}{suffix}"
1056+
unique_name = self.model_resolver.get_class_name(variant_name, unique=False).name
1057+
path_parts[-1] = unique_name
1058+
variant_ref = self.model_resolver.add(path_parts, unique_name, class_name=True, unique=False)
1059+
data_type.reference = variant_ref
1060+
elif not self._ref_schema_has_model(ref_path):
1061+
if not hasattr(self, "_force_base_model_refs"):
1062+
self._force_base_model_refs: set[str] = set()
1063+
self._force_base_model_refs.add(ref_path)
1064+
for nested_dt in data_type.data_types:
1065+
self._update_data_type_ref_for_variant(nested_dt, suffix)
1066+
1067+
def _update_field_refs_for_variant(
1068+
self, model_fields: list[DataModelFieldBase], suffix: str
1069+
) -> list[DataModelFieldBase]:
1070+
"""Update field references in model_fields to point to variant models.
1071+
1072+
For Request models, refs should point to Request variants.
1073+
For Response models, refs should point to Response variants.
1074+
"""
1075+
if self.read_only_write_only_model_type != ReadOnlyWriteOnlyModelType.RequestResponse:
1076+
return model_fields
1077+
for field in model_fields:
1078+
if field.data_type:
1079+
self._update_data_type_ref_for_variant(field.data_type, suffix)
1080+
return model_fields
1081+
1082+
def _generate_forced_base_models(self) -> None:
1083+
"""Generate base models for schemas that are referenced as property types but lack models."""
1084+
if not hasattr(self, "_force_base_model_refs"):
1085+
return
1086+
if not self._force_base_model_refs: # pragma: no cover
1087+
return
1088+
1089+
existing_model_paths = {result.path for result in self.results}
1090+
1091+
for ref_path in sorted(self._force_base_model_refs):
1092+
if ref_path in existing_model_paths: # pragma: no cover
1093+
continue
1094+
try:
1095+
ref_schema = self._load_ref_schema_object(ref_path)
1096+
path_parts = ref_path.split("/")
1097+
schema_name = path_parts[-1]
1098+
1099+
original_method = self._should_generate_base_model
1100+
1101+
def force_base_model(*, generates_separate_models: bool = False) -> bool: # noqa: ARG001
1102+
return True
1103+
1104+
self._should_generate_base_model = force_base_model # type: ignore[method-assign]
1105+
try:
1106+
self.parse_obj(schema_name, ref_schema, path_parts)
1107+
finally:
1108+
self._should_generate_base_model = original_method # type: ignore[method-assign]
1109+
except Exception: # noqa: BLE001, S110 # pragma: no cover
1110+
pass
1111+
9821112
def _create_variant_model( # noqa: PLR0913, PLR0917
9831113
self,
9841114
path: list[str],
@@ -991,6 +1121,8 @@ def _create_variant_model( # noqa: PLR0913, PLR0917
9911121
"""Create a Request or Response model variant."""
9921122
if not model_fields:
9931123
return
1124+
# Update field refs to point to variant models when in request-response mode
1125+
self._update_field_refs_for_variant(model_fields, suffix)
9941126
variant_name = f"{base_name}{suffix}"
9951127
unique_name = self.model_resolver.get_class_name(variant_name, unique=True).name
9961128
model_path = [*path[:-1], unique_name]
@@ -3720,6 +3852,7 @@ def parse_raw(self) -> None:
37203852
self._parse_file(self.raw_obj, obj_name, path_parts)
37213853

37223854
self._resolve_unparsed_json_pointer()
3855+
self._generate_forced_base_models()
37233856

37243857
def _resolve_unparsed_json_pointer(self) -> None:
37253858
"""Resolve any remaining unparsed JSON pointer references recursively."""

src/datamodel_code_generator/parser/openapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,7 @@ def parse_raw(self) -> None: # noqa: PLR0912
794794
)
795795

796796
self._resolve_unparsed_json_pointer()
797+
self._generate_forced_base_models()
797798

798799
def _collect_discriminator_schemas(self) -> None:
799800
"""Collect schemas with discriminators but no oneOf/anyOf, and find their subtypes."""
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# generated by datamodel-codegen:
2+
# filename: read_only_write_only_ref_request_response.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import BaseModel
8+
9+
10+
class ChildMixedRequest(BaseModel):
11+
name: str | None = None
12+
13+
14+
class ParentWithMixedChildRequest(BaseModel):
15+
child: ChildMixedRequest | None = None
16+
17+
18+
class ParentWithChildListRequest(BaseModel):
19+
children: list[ChildMixedRequest] | None = None
20+
21+
22+
class ChildOnlyReadOnly(BaseModel):
23+
id: int | None = None
24+
25+
26+
class ChildOnlyWriteOnly(BaseModel):
27+
secret: str | None = None
28+
29+
30+
class ParentWithOnlyReadOnlyChildRequest(BaseModel):
31+
child: ChildOnlyReadOnly | None = None
32+
33+
34+
class ParentWithOnlyWriteOnlyChildResponse(BaseModel):
35+
child: ChildOnlyWriteOnly | None = None
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Read Only Write Only Ref Request Response Test API
4+
version: "1.0"
5+
paths: {}
6+
components:
7+
schemas:
8+
# Child with only readOnly fields - should generate base model when referenced directly
9+
ChildOnlyReadOnly:
10+
type: object
11+
properties:
12+
id:
13+
readOnly: true
14+
type: integer
15+
# Child with only writeOnly fields - should generate base model when referenced directly
16+
ChildOnlyWriteOnly:
17+
type: object
18+
properties:
19+
secret:
20+
writeOnly: true
21+
type: string
22+
# Child with mixed fields - should generate ChildRequest variant
23+
ChildMixed:
24+
type: object
25+
properties:
26+
id:
27+
readOnly: true
28+
type: integer
29+
name:
30+
type: string
31+
# Parent referencing child with only readOnly fields
32+
ParentWithOnlyReadOnlyChild:
33+
type: object
34+
properties:
35+
child:
36+
$ref: "#/components/schemas/ChildOnlyReadOnly"
37+
name:
38+
readOnly: true
39+
type: string
40+
# Parent referencing child with only writeOnly fields
41+
ParentWithOnlyWriteOnlyChild:
42+
type: object
43+
properties:
44+
child:
45+
$ref: "#/components/schemas/ChildOnlyWriteOnly"
46+
secret:
47+
writeOnly: true
48+
type: string
49+
# Parent referencing child with mixed fields
50+
ParentWithMixedChild:
51+
type: object
52+
properties:
53+
child:
54+
$ref: "#/components/schemas/ChildMixed"
55+
name:
56+
readOnly: true
57+
type: string
58+
# Parent with list of children (nested type reference)
59+
ParentWithChildList:
60+
type: object
61+
properties:
62+
children:
63+
type: array
64+
items:
65+
$ref: "#/components/schemas/ChildMixed"
66+
name:
67+
readOnly: true
68+
type: string

tests/main/openapi/test_main_openapi.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4421,6 +4421,29 @@ def test_main_openapi_read_only_write_only_empty_base(output_file: Path) -> None
44214421
)
44224422

44234423

4424+
def test_main_openapi_read_only_write_only_ref_request_response(output_file: Path) -> None:
4425+
"""Test readOnly/writeOnly with $ref in request-response mode (issue #2940).
4426+
4427+
When a schema references another schema via $ref:
4428+
- If the referenced schema generates variants, use the variant reference
4429+
- If the referenced schema would have no model (only readOnly/writeOnly fields),
4430+
force generation of the base model
4431+
"""
4432+
run_main_and_assert(
4433+
input_path=OPEN_API_DATA_PATH / "read_only_write_only_ref_request_response.yaml",
4434+
output_path=output_file,
4435+
input_file_type="openapi",
4436+
assert_func=assert_file_content,
4437+
expected_file="read_only_write_only_ref_request_response.py",
4438+
extra_args=[
4439+
"--output-model-type",
4440+
"pydantic_v2.BaseModel",
4441+
"--read-only-write-only-model-type",
4442+
"request-response",
4443+
],
4444+
)
4445+
4446+
44244447
def test_main_openapi_dot_notation_inheritance(output_dir: Path) -> None:
44254448
"""Test dot notation in schema names with inheritance."""
44264449
run_main_and_assert(

0 commit comments

Comments
 (0)