Skip to content

Commit e57ebd2

Browse files
authored
Fix $ref handling in request-response mode for readOnly/writeOnly schemas (#2942)
* Fix $ref handling in request-response mode for readOnly/writeOnly schemas * Add read_only_write_only feature flag for schema version * Fix coverage for defensive branches
1 parent c4d4b8d commit e57ebd2

File tree

9 files changed

+383
-5
lines changed

9 files changed

+383
-5
lines changed

src/datamodel_code_generator/parser/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ def get_ancestors(
526526
continue
527527
ancestors.add(parent_path)
528528
parent_model = sorted_data_models.get(parent_path)
529-
if not parent_model:
529+
if not parent_model: # pragma: no cover
530530
continue
531531
to_visit.extend(
532532
bc.reference.path

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -993,12 +993,139 @@ def _should_generate_separate_models(
993993

994994
def _should_generate_base_model(self, *, generates_separate_models: bool = False) -> bool:
995995
"""Determine if Base model should be generated."""
996+
if getattr(self, "_force_base_model_generation", False):
997+
return True
996998
if self.read_only_write_only_model_type is None:
997999
return True
9981000
if self.read_only_write_only_model_type == ReadOnlyWriteOnlyModelType.All:
9991001
return True
10001002
return not generates_separate_models
10011003

1004+
def _ref_schema_generates_variant(self, ref_path: str, suffix: str) -> bool:
1005+
"""Check if a referenced schema will generate a specific variant (Request or Response).
1006+
1007+
For Request variant: schema must have readOnly fields AND at least one non-readOnly field.
1008+
For Response variant: schema must have writeOnly fields AND at least one non-writeOnly field.
1009+
"""
1010+
try:
1011+
ref_schema = self._load_ref_schema_object(ref_path)
1012+
except Exception: # noqa: BLE001 # pragma: no cover
1013+
return False
1014+
1015+
has_read_only = False
1016+
has_write_only = False
1017+
has_non_read_only = False
1018+
has_non_write_only = False
1019+
1020+
for prop in (ref_schema.properties or {}).values():
1021+
if not isinstance(prop, JsonSchemaObject): # pragma: no cover
1022+
continue
1023+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1024+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1025+
if is_read_only:
1026+
has_read_only = True
1027+
else:
1028+
has_non_read_only = True
1029+
if is_write_only:
1030+
has_write_only = True
1031+
else:
1032+
has_non_write_only = True
1033+
1034+
if suffix == "Request":
1035+
return has_read_only and has_non_read_only
1036+
if suffix == "Response":
1037+
return has_write_only and has_non_write_only
1038+
return False # pragma: no cover
1039+
1040+
def _ref_schema_has_model(self, ref_path: str) -> bool:
1041+
"""Check if a referenced schema will have a model (base or variant) generated.
1042+
1043+
Returns False if the schema has only readOnly or only writeOnly fields in request-response mode,
1044+
which would result in no model being generated at all.
1045+
"""
1046+
try:
1047+
ref_schema = self._load_ref_schema_object(ref_path)
1048+
except Exception: # noqa: BLE001 # pragma: no cover
1049+
return True
1050+
1051+
has_read_only = False
1052+
has_write_only = False
1053+
1054+
for prop in (ref_schema.properties or {}).values():
1055+
if not isinstance(prop, JsonSchemaObject): # pragma: no cover
1056+
continue
1057+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1058+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1059+
if is_read_only:
1060+
has_read_only = True
1061+
elif is_write_only:
1062+
has_write_only = True
1063+
else: # pragma: no cover
1064+
return True
1065+
1066+
if has_read_only and not has_write_only:
1067+
return False
1068+
return not (has_write_only and not has_read_only)
1069+
1070+
def _update_data_type_ref_for_variant(self, data_type: DataType, suffix: str) -> None:
1071+
"""Recursively update data type references to point to variant models."""
1072+
if data_type.reference:
1073+
ref_path = data_type.reference.path
1074+
if self._ref_schema_generates_variant(ref_path, suffix):
1075+
path_parts = ref_path.split("/")
1076+
base_name = path_parts[-1]
1077+
variant_name = f"{base_name}{suffix}"
1078+
unique_name = self.model_resolver.get_class_name(variant_name, unique=False).name
1079+
path_parts[-1] = unique_name
1080+
variant_ref = self.model_resolver.add(path_parts, unique_name, class_name=True, unique=False)
1081+
data_type.reference = variant_ref
1082+
elif not self._ref_schema_has_model(ref_path): # pragma: no branch
1083+
if not hasattr(self, "_force_base_model_refs"):
1084+
self._force_base_model_refs: set[str] = set()
1085+
self._force_base_model_refs.add(ref_path)
1086+
for nested_dt in data_type.data_types:
1087+
self._update_data_type_ref_for_variant(nested_dt, suffix)
1088+
1089+
def _update_field_refs_for_variant(
1090+
self, model_fields: list[DataModelFieldBase], suffix: str
1091+
) -> list[DataModelFieldBase]:
1092+
"""Update field references in model_fields to point to variant models.
1093+
1094+
For Request models, refs should point to Request variants.
1095+
For Response models, refs should point to Response variants.
1096+
"""
1097+
if self.read_only_write_only_model_type != ReadOnlyWriteOnlyModelType.RequestResponse:
1098+
return model_fields
1099+
for field in model_fields:
1100+
if field.data_type: # pragma: no branch
1101+
self._update_data_type_ref_for_variant(field.data_type, suffix)
1102+
return model_fields
1103+
1104+
def _generate_forced_base_models(self) -> None:
1105+
"""Generate base models for schemas that are referenced as property types but lack models."""
1106+
if not hasattr(self, "_force_base_model_refs"):
1107+
return
1108+
if not self._force_base_model_refs: # pragma: no cover
1109+
return
1110+
1111+
existing_model_paths = {result.path for result in self.results}
1112+
1113+
for ref_path in sorted(self._force_base_model_refs):
1114+
if ref_path in existing_model_paths: # pragma: no cover
1115+
continue
1116+
try:
1117+
ref_schema = self._load_ref_schema_object(ref_path)
1118+
path_parts = ref_path.split("/")
1119+
schema_name = path_parts[-1]
1120+
1121+
self._force_base_model_generation = True
1122+
try:
1123+
self.parse_obj(schema_name, ref_schema, path_parts)
1124+
finally:
1125+
self._force_base_model_generation = False
1126+
except Exception: # noqa: BLE001, S110 # pragma: no cover
1127+
pass
1128+
10021129
def _create_variant_model( # noqa: PLR0913, PLR0917
10031130
self,
10041131
path: list[str],
@@ -1011,6 +1138,8 @@ def _create_variant_model( # noqa: PLR0913, PLR0917
10111138
"""Create a Request or Response model variant."""
10121139
if not model_fields:
10131140
return
1141+
# Update field refs to point to variant models when in request-response mode
1142+
self._update_field_refs_for_variant(model_fields, suffix)
10141143
variant_name = f"{base_name}{suffix}"
10151144
unique_name = self.model_resolver.get_class_name(variant_name, unique=True).name
10161145
model_path = [*path[:-1], unique_name]
@@ -3645,7 +3774,7 @@ def parse_raw_obj(
36453774
obj = self._validate_schema_object(raw, path)
36463775
self.parse_obj(name, obj, path)
36473776

3648-
def _check_version_specific_features(
3777+
def _check_version_specific_features( # noqa: PLR0912
36493778
self,
36503779
raw: dict[str, YamlValue] | YamlValue,
36513780
path: list[str],
@@ -3709,6 +3838,18 @@ def _check_version_specific_features(
37093838
stacklevel=3,
37103839
)
37113840

3841+
if not self.schema_features.read_only_write_only:
3842+
if raw.get("readOnly") is True:
3843+
warn(
3844+
f"readOnly is not supported in this schema version (Draft 7+ only). Schema path: {'/'.join(path)}",
3845+
stacklevel=3,
3846+
)
3847+
if raw.get("writeOnly") is True:
3848+
warn(
3849+
f"writeOnly is not supported in this schema version (Draft 7+ only). Schema path: {'/'.join(path)}",
3850+
stacklevel=3,
3851+
)
3852+
37123853
def _check_array_version_features(
37133854
self,
37143855
obj: JsonSchemaObject,
@@ -3846,6 +3987,7 @@ def parse_raw(self) -> None:
38463987
self._parse_file(self.raw_obj, obj_name, path_parts)
38473988

38483989
self._resolve_unparsed_json_pointer()
3990+
self._generate_forced_base_models()
38493991

38503992
def _resolve_unparsed_json_pointer(self) -> None:
38513993
"""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
@@ -800,6 +800,7 @@ def parse_raw(self) -> None: # noqa: PLR0912
800800
)
801801

802802
self._resolve_unparsed_json_pointer()
803+
self._generate_forced_base_models()
803804

804805
def _collect_discriminator_schemas(self) -> None:
805806
"""Collect schemas with discriminators but no oneOf/anyOf, and find their subtypes."""

src/datamodel_code_generator/parser/schema_version.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class JsonSchemaFeatures:
3131
id_field: The field name for schema ID ("id" for Draft 4, "$id" for Draft 6+).
3232
definitions_key: The key for definitions ("definitions" or "$defs").
3333
exclusive_as_number: Draft 6+ uses numeric exclusiveMin/Max (Draft 4 uses boolean).
34+
read_only_write_only: Draft 7+ supports readOnly/writeOnly keywords.
3435
"""
3536

3637
null_in_type_array: bool
@@ -40,6 +41,7 @@ class JsonSchemaFeatures:
4041
id_field: str
4142
definitions_key: str
4243
exclusive_as_number: bool
44+
read_only_write_only: bool
4345

4446
@classmethod
4547
def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
@@ -54,8 +56,9 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
5456
id_field="id",
5557
definitions_key="definitions",
5658
exclusive_as_number=False,
59+
read_only_write_only=False,
5760
)
58-
case JsonSchemaVersion.Draft6 | JsonSchemaVersion.Draft7:
61+
case JsonSchemaVersion.Draft6:
5962
return cls(
6063
null_in_type_array=False,
6164
defs_not_definitions=False,
@@ -64,6 +67,18 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
6467
id_field="$id",
6568
definitions_key="definitions",
6669
exclusive_as_number=True,
70+
read_only_write_only=False,
71+
)
72+
case JsonSchemaVersion.Draft7:
73+
return cls(
74+
null_in_type_array=False,
75+
defs_not_definitions=False,
76+
prefix_items=False,
77+
boolean_schemas=True,
78+
id_field="$id",
79+
definitions_key="definitions",
80+
exclusive_as_number=True,
81+
read_only_write_only=True,
6782
)
6883
case JsonSchemaVersion.Draft201909:
6984
return cls(
@@ -74,6 +89,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
7489
id_field="$id",
7590
definitions_key="$defs",
7691
exclusive_as_number=True,
92+
read_only_write_only=True,
7793
)
7894
case _:
7995
return cls(
@@ -84,6 +100,7 @@ def from_version(cls, version: JsonSchemaVersion) -> JsonSchemaFeatures:
84100
id_field="$id",
85101
definitions_key="$defs",
86102
exclusive_as_number=True,
103+
read_only_write_only=True,
87104
)
88105

89106

@@ -106,8 +123,6 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
106123
"""Create OpenAPISchemaFeatures from an OpenAPI version."""
107124
match version:
108125
case OpenAPIVersion.V30:
109-
# OpenAPI 3.0 schema dialect inherits JSON Schema Draft 4 semantics
110-
# where exclusiveMinimum/Maximum are boolean values
111126
return cls(
112127
null_in_type_array=False,
113128
defs_not_definitions=False,
@@ -116,6 +131,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
116131
id_field="$id",
117132
definitions_key="definitions",
118133
exclusive_as_number=False,
134+
read_only_write_only=True,
119135
nullable_keyword=True,
120136
discriminator_support=True,
121137
)
@@ -128,6 +144,7 @@ def from_openapi_version(cls, version: OpenAPIVersion) -> OpenAPISchemaFeatures:
128144
id_field="$id",
129145
definitions_key="$defs",
130146
exclusive_as_number=True,
147+
read_only_write_only=True,
131148
nullable_keyword=False,
132149
discriminator_support=True,
133150
)
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

0 commit comments

Comments
 (0)