Skip to content

Commit d825aec

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

5 files changed

Lines changed: 264 additions & 1 deletion

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,106 @@ 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+
9821082
def _create_variant_model( # noqa: PLR0913, PLR0917
9831083
self,
9841084
path: list[str],
@@ -991,6 +1091,8 @@ def _create_variant_model( # noqa: PLR0913, PLR0917
9911091
"""Create a Request or Response model variant."""
9921092
if not model_fields:
9931093
return
1094+
# Update field refs to point to variant models when in request-response mode
1095+
self._update_field_refs_for_variant(model_fields, suffix)
9941096
variant_name = f"{base_name}{suffix}"
9951097
unique_name = self.model_resolver.get_class_name(variant_name, unique=True).name
9961098
model_path = [*path[:-1], unique_name]

src/datamodel_code_generator/parser/openapi.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,8 +699,43 @@ def parse_operation(
699699
path=[*path, "tags"],
700700
)
701701

702-
def parse_raw(self) -> None: # noqa: PLR0912
702+
def parse_raw(self) -> None:
703703
"""Parse OpenAPI specification including schemas, paths, and operations."""
704+
self._parse_raw_impl()
705+
self._generate_forced_base_models()
706+
707+
def _generate_forced_base_models(self) -> None:
708+
"""Generate base models for schemas that are referenced as property types but lack models."""
709+
if not hasattr(self, "_force_base_model_refs"):
710+
return
711+
if not self._force_base_model_refs: # pragma: no cover
712+
return
713+
714+
existing_model_paths = {result.path for result in self.results}
715+
716+
for ref_path in sorted(self._force_base_model_refs):
717+
if ref_path in existing_model_paths: # pragma: no cover
718+
continue
719+
try:
720+
ref_schema = self._load_ref_schema_object(ref_path)
721+
path_parts = ref_path.split("/")
722+
schema_name = path_parts[-1]
723+
724+
original_method = self._should_generate_base_model
725+
726+
def force_base_model(*, generates_separate_models: bool = False) -> bool: # noqa: ARG001
727+
return True
728+
729+
self._should_generate_base_model = force_base_model # type: ignore[method-assign]
730+
try:
731+
self.parse_obj(schema_name, ref_schema, path_parts)
732+
finally:
733+
self._should_generate_base_model = original_method # type: ignore[method-assign]
734+
except Exception: # noqa: BLE001, S110 # pragma: no cover
735+
pass
736+
737+
def _parse_raw_impl(self) -> None: # noqa: PLR0912
738+
"""Parse OpenAPI raw data implementation."""
704739
for source, path_parts in self._get_context_source_path_parts():
705740
if self.validation:
706741
warn(
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)