Skip to content

Commit 4755094

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

5 files changed

Lines changed: 282 additions & 1 deletion

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,117 @@ 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+
# Collect all fields from the schema
994+
has_read_only = False
995+
has_write_only = False
996+
has_non_read_only = False
997+
has_non_write_only = False
998+
999+
if ref_schema.properties:
1000+
for prop in ref_schema.properties.values():
1001+
if isinstance(prop, JsonSchemaObject):
1002+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1003+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1004+
if is_read_only:
1005+
has_read_only = True
1006+
else:
1007+
has_non_read_only = True
1008+
if is_write_only:
1009+
has_write_only = True
1010+
else:
1011+
has_non_write_only = True
1012+
1013+
# For Request variant: needs readOnly fields AND at least one non-readOnly field remaining
1014+
if suffix == "Request":
1015+
return has_read_only and has_non_read_only
1016+
# For Response variant: needs writeOnly fields AND at least one non-writeOnly field remaining
1017+
if suffix == "Response":
1018+
return has_write_only and has_non_write_only
1019+
return False # pragma: no cover - only Request and Response suffixes are used
1020+
1021+
def _ref_schema_has_model(self, ref_path: str) -> bool:
1022+
"""Check if a referenced schema will have a model (base or variant) generated.
1023+
1024+
Returns False if the schema has only readOnly or only writeOnly fields in request-response mode,
1025+
which would result in no model being generated at all.
1026+
"""
1027+
try:
1028+
ref_schema = self._load_ref_schema_object(ref_path)
1029+
except Exception: # noqa: BLE001 # pragma: no cover
1030+
return True # Assume it exists if we can't load it
1031+
1032+
# Check if the schema has any fields that aren't readOnly or writeOnly
1033+
has_read_only = False
1034+
has_write_only = False
1035+
1036+
if ref_schema.properties:
1037+
for prop in ref_schema.properties.values():
1038+
if isinstance(prop, JsonSchemaObject):
1039+
is_read_only = self._resolve_field_flag(prop, "readOnly")
1040+
is_write_only = self._resolve_field_flag(prop, "writeOnly")
1041+
if is_read_only:
1042+
has_read_only = True
1043+
elif is_write_only:
1044+
has_write_only = True
1045+
else: # pragma: no cover - plain fields in refs to non-variant schemas
1046+
# Has a plain field - schema will have a model
1047+
return True
1048+
1049+
# Has ONLY readOnly fields - no model in request-response mode
1050+
if has_read_only and not has_write_only:
1051+
return False
1052+
# Has ONLY writeOnly fields - no model, or has both/neither - has model
1053+
return not (has_write_only and not has_read_only)
1054+
1055+
def _update_data_type_ref_for_variant(self, data_type: DataType, suffix: str) -> None:
1056+
"""Recursively update data type references to point to variant models."""
1057+
if data_type.reference:
1058+
ref_path = data_type.reference.path
1059+
if self._ref_schema_generates_variant(ref_path, suffix):
1060+
# The referenced schema will have a variant model - update the ref
1061+
path_parts = ref_path.split("/")
1062+
base_name = path_parts[-1]
1063+
variant_name = f"{base_name}{suffix}"
1064+
unique_name = self.model_resolver.get_class_name(variant_name, unique=False).name
1065+
# Create the variant path (e.g., "#/components/schemas/ChildRequest")
1066+
path_parts[-1] = unique_name
1067+
variant_ref = self.model_resolver.add(path_parts, unique_name, class_name=True, unique=False)
1068+
data_type.reference = variant_ref
1069+
elif not self._ref_schema_has_model(ref_path):
1070+
# The referenced schema won't have any model - mark it for forced base model generation
1071+
if not hasattr(self, "_force_base_model_refs"):
1072+
self._force_base_model_refs: set[str] = set()
1073+
self._force_base_model_refs.add(ref_path)
1074+
# Recursively update nested data types (for List[Child], Optional[Child], etc.)
1075+
for nested_dt in data_type.data_types:
1076+
self._update_data_type_ref_for_variant(nested_dt, suffix)
1077+
1078+
def _update_field_refs_for_variant(
1079+
self, model_fields: list[DataModelFieldBase], suffix: str
1080+
) -> list[DataModelFieldBase]:
1081+
"""Update field references in model_fields to point to variant models.
1082+
1083+
For Request models, refs should point to Request variants.
1084+
For Response models, refs should point to Response variants.
1085+
"""
1086+
if self.read_only_write_only_model_type != ReadOnlyWriteOnlyModelType.RequestResponse:
1087+
return model_fields
1088+
for field in model_fields:
1089+
if field.data_type:
1090+
self._update_data_type_ref_for_variant(field.data_type, suffix)
1091+
return model_fields
1092+
9821093
def _create_variant_model( # noqa: PLR0913, PLR0917
9831094
self,
9841095
path: list[str],
@@ -991,6 +1102,8 @@ def _create_variant_model( # noqa: PLR0913, PLR0917
9911102
"""Create a Request or Response model variant."""
9921103
if not model_fields:
9931104
return
1105+
# Update field refs to point to variant models when in request-response mode
1106+
self._update_field_refs_for_variant(model_fields, suffix)
9941107
variant_name = f"{base_name}{suffix}"
9951108
unique_name = self.model_resolver.get_class_name(variant_name, unique=True).name
9961109
model_path = [*path[:-1], unique_name]

src/datamodel_code_generator/parser/openapi.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,8 +699,50 @@ 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+
# Post-process: generate base models for schemas that are referenced as property types
706+
# but would otherwise have no model in request-response mode
707+
self._generate_forced_base_models()
708+
709+
def _generate_forced_base_models(self) -> None:
710+
"""Generate base models for schemas that are referenced as property types but lack models."""
711+
if not hasattr(self, "_force_base_model_refs"):
712+
return
713+
if not self._force_base_model_refs: # pragma: no cover
714+
return
715+
716+
# Get the set of schema paths that already have models
717+
existing_model_paths = {result.path for result in self.results}
718+
719+
for ref_path in sorted(self._force_base_model_refs):
720+
if ref_path in existing_model_paths: # pragma: no cover
721+
continue # Already has a model
722+
try:
723+
ref_schema = self._load_ref_schema_object(ref_path)
724+
# Get the name from the path
725+
path_parts = ref_path.split("/")
726+
schema_name = path_parts[-1]
727+
728+
# Parse the schema with forced base model generation
729+
# Temporarily override _should_generate_base_model
730+
original_method = self._should_generate_base_model
731+
732+
def force_base_model(*, generates_separate_models: bool = False) -> bool: # noqa: ARG001
733+
return True
734+
735+
self._should_generate_base_model = force_base_model # type: ignore[method-assign]
736+
try:
737+
# Re-parse the object to generate the base model
738+
self.parse_obj(schema_name, ref_schema, path_parts)
739+
finally:
740+
self._should_generate_base_model = original_method # type: ignore[method-assign]
741+
except Exception: # noqa: BLE001, S110 # pragma: no cover
742+
pass # Skip if we can't load or parse the schema
743+
744+
def _parse_raw_impl(self) -> None: # noqa: PLR0912
745+
"""Parse OpenAPI raw data implementation."""
704746
for source, path_parts in self._get_context_source_path_parts():
705747
if self.validation:
706748
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)