Skip to content

Commit 52288ff

Browse files
Add --read-only-write-only-model-type option for OpenAPI readOnly/writeOnly support (#2587)
* Add support for readOnly/writeOnly model generation strategy * docs: update command help in README 🤖 Generated by GitHub Actions * refactor: streamline readOnly/writeOnly resolution and model generation * feat: enhance readOnly/writeOnly model handling and add related tests * feat: implement readOnly/writeOnly model handling with Union type support and add related tests * fix: improve deep copy handling in _copy_field method for DataModelFieldBase * feat: update path parameter handling and add comprehensive tests for readOnly/writeOnly models * feat: add readOnly/writeOnly model handling with allOf support and related tests * feat: add test for readOnly/writeOnly with allOf in request-response mode and generate related model * fix: improve reference resolution handling in _iter_fields_from_schema method * fix: ensure field_key is not None before deduplicating fields in _collect_all_fields * feat: enhance reference field iteration with visited tracking to prevent infinite recursion * fix: handle unresolved references in allOf and skip None field keys during deduplication * feat: update base model generation logic to support readOnly/writeOnly configurations and add related tests * feat: implement readOnly/writeOnly flag resolution and update model generation logic * fix: refactor source handling in field iteration to improve clarity and maintainability * fix: simplify conditional logic in field iteration for improved readability * refactor: streamline unique model name generation to avoid collisions * feat: add deep copy functionality for DataModelFieldBase and enhance field iteration to include inherited fields * refactor: remove redundant iter_all_fields method to simplify field iteration logic * refactor: simplify readOnly and writeOnly resolution logic in JsonSchemaObject * Refactor: simplify readOnly and writeOnly resolution logic in JsonSchemaObject * Refactor: unify readOnly and writeOnly flag resolution logic and enhance field iteration * Refactor: streamline field lookup logic in DataModel and TypedDict classes * Add tests for readOnly/writeOnly detection in anyOf and oneOf compositions * Refactor: remove duplicate inheritance in Parent class and enhance base class field resolution * Add tests for readOnly/writeOnly behavior with shared base references and empty base classes --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 91df24a commit 52288ff

56 files changed

Lines changed: 2077 additions & 56 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,10 @@ OpenAPI-only options:
541541
query parameters (Only OpenAPI)
542542
--openapi-scopes {schemas,paths,tags,parameters,webhooks} [{schemas,paths,tags,parameters,webhooks} ...]
543543
Scopes of OpenAPI model generation (default: schemas)
544+
--read-only-write-only-model-type {request-response,all}
545+
Model generation for readOnly/writeOnly fields: ''request-response'' =
546+
Request/Response models only (no base model), ''all'' = Base + Request
547+
+ Response models.
544548
--strict-nullable Treat default field as a non-nullable field (Only OpenAPI)
545549
--use-operation-id-as-name
546550
use operation id of OpenAPI as class names of models

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,10 @@ OpenAPI-only options:
533533
query parameters (Only OpenAPI)
534534
--openapi-scopes {schemas,paths,tags,parameters,webhooks} [{schemas,paths,tags,parameters,webhooks} ...]
535535
Scopes of OpenAPI model generation (default: schemas)
536+
--read-only-write-only-model-type {request-response,all}
537+
Model generation for readOnly/writeOnly fields: ''request-response'' =
538+
Request/Response models only (no base model), ''all'' = Base + Request
539+
+ Response models.
536540
--strict-nullable Treat default field as a non-nullable field (Only OpenAPI)
537541
--use-operation-id-as-name
538542
use operation id of OpenAPI as class names of models

docs/openapi.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,138 @@ class Api(BaseModel):
210210
class Apis(BaseModel):
211211
__root__: List[Api]
212212
```
213+
214+
## readOnly / writeOnly Properties
215+
216+
OpenAPI 3.x supports `readOnly` and `writeOnly` property annotations:
217+
218+
- **readOnly**: Property is only returned in responses (e.g., `id`, `created_at`)
219+
- **writeOnly**: Property is only sent in requests (e.g., `password`)
220+
221+
### Option: `--read-only-write-only-model-type`
222+
223+
This option generates separate Request/Response models based on these annotations.
224+
225+
| Value | Description |
226+
|-------|-------------|
227+
| (not set) | Default. No special handling (backward compatible) |
228+
| `request-response` | Generate only Request/Response models (no base model) |
229+
| `all` | Generate base model + Request + Response models |
230+
231+
### Example Schema
232+
233+
```yaml
234+
openapi: "3.0.0"
235+
info:
236+
title: User API
237+
version: "1.0"
238+
paths: {}
239+
components:
240+
schemas:
241+
User:
242+
type: object
243+
required:
244+
- id
245+
- name
246+
properties:
247+
id:
248+
type: integer
249+
readOnly: true # Server-generated, not in requests
250+
name:
251+
type: string
252+
password:
253+
type: string
254+
writeOnly: true # Client-only, not in responses
255+
created_at:
256+
type: string
257+
format: date-time
258+
readOnly: true
259+
```
260+
261+
### Generated Output
262+
263+
```bash
264+
$ datamodel-codegen --input user.yaml --input-file-type openapi \
265+
--output-model-type pydantic_v2.BaseModel \
266+
--read-only-write-only-model-type all
267+
```
268+
269+
```python
270+
from pydantic import BaseModel
271+
from typing import Optional
272+
from datetime import datetime
273+
274+
# Request model: excludes readOnly fields (id, created_at)
275+
class UserRequest(BaseModel):
276+
name: str
277+
password: Optional[str] = None
278+
279+
# Response model: excludes writeOnly fields (password)
280+
class UserResponse(BaseModel):
281+
id: int
282+
name: str
283+
created_at: Optional[datetime] = None
284+
285+
# Base model: contains all fields
286+
class User(BaseModel):
287+
id: int
288+
name: str
289+
password: Optional[str] = None
290+
created_at: Optional[datetime] = None
291+
```
292+
293+
### Usage Patterns
294+
295+
| Use Case | Recommended Option | Generated Models |
296+
|----------|-------------------|------------------|
297+
| API client validation | `request-response` | `UserRequest`, `UserResponse` |
298+
| Database ORM mapping | (not set) | `User` |
299+
| Both client & ORM | `all` | `User`, `UserRequest`, `UserResponse` |
300+
301+
### Behavior with allOf Inheritance
302+
303+
When using `allOf` with `$ref`, fields from all referenced schemas are flattened into Request/Response models:
304+
305+
```yaml
306+
components:
307+
schemas:
308+
Timestamps:
309+
type: object
310+
properties:
311+
created_at:
312+
type: string
313+
format: date-time
314+
readOnly: true
315+
316+
User:
317+
allOf:
318+
- $ref: "#/components/schemas/Timestamps"
319+
- type: object
320+
properties:
321+
name:
322+
type: string
323+
```
324+
325+
Generated `UserRequest` will exclude `created_at` (readOnly from Timestamps).
326+
327+
### Collision Handling
328+
329+
If a schema named `UserRequest` or `UserResponse` already exists, the generated model will be named `UserRequestModel` or `UserResponseModel` to avoid conflicts.
330+
331+
### Supported Output Formats
332+
333+
This option works with all output formats:
334+
335+
- `pydantic.BaseModel` / `pydantic_v2.BaseModel`
336+
- `dataclasses.dataclass`
337+
- `typing.TypedDict`
338+
- `msgspec.Struct`
339+
340+
### Supported $ref Types
341+
342+
readOnly/writeOnly resolution works with local and file reference types:
343+
344+
| Reference Type | Example | Support |
345+
|---------------|---------|---------|
346+
| Local | `#/components/schemas/User` | ✅ |
347+
| File | `./common.yaml#/User` | ✅ |

src/datamodel_code_generator/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,17 @@ class GraphQLScope(Enum):
276276
Schema = "schema"
277277

278278

279+
class ReadOnlyWriteOnlyModelType(Enum):
280+
"""Model generation strategy for readOnly/writeOnly fields.
281+
282+
RequestResponse: Generate only Request/Response model variants (no base model).
283+
All: Generate Base, Request, and Response models.
284+
"""
285+
286+
RequestResponse = "request-response"
287+
All = "all"
288+
289+
279290
class Error(Exception):
280291
"""Base exception for datamodel-code-generator errors."""
281292

@@ -435,6 +446,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
435446
dataclass_arguments: DataclassArguments | None = None,
436447
disable_future_imports: bool = False,
437448
type_mappings: list[str] | None = None,
449+
read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None,
438450
all_exports_scope: AllExportsScope | None = None,
439451
all_exports_collision_strategy: AllExportsCollisionStrategy | None = None,
440452
) -> None:
@@ -670,6 +682,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]:
670682
parent_scoped_naming=parent_scoped_naming,
671683
dataclass_arguments=dataclass_arguments,
672684
type_mappings=type_mappings,
685+
read_only_write_only_model_type=read_only_write_only_model_type,
673686
**kwargs,
674687
)
675688

@@ -815,5 +828,6 @@ def infer_input_type(text: str) -> InputFileType:
815828
"InvalidClassNameError",
816829
"LiteralType",
817830
"PythonVersion",
831+
"ReadOnlyWriteOnlyModelType",
818832
"generate",
819833
]

src/datamodel_code_generator/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
InputFileType,
3131
InvalidClassNameError,
3232
OpenAPIScope,
33+
ReadOnlyWriteOnlyModelType,
3334
ReuseScope,
3435
enable_debug_message,
3536
generate,
@@ -448,6 +449,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict
448449
parent_scoped_naming: bool = False
449450
disable_future_imports: bool = False
450451
type_mappings: Optional[list[str]] = None # noqa: UP045
452+
read_only_write_only_model_type: Optional[ReadOnlyWriteOnlyModelType] = None # noqa: UP045
451453
all_exports_scope: Optional[AllExportsScope] = None # noqa: UP045
452454
all_exports_collision_strategy: Optional[AllExportsCollisionStrategy] = None # noqa: UP045
453455

@@ -851,6 +853,7 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
851853
dataclass_arguments=config.dataclass_arguments,
852854
disable_future_imports=config.disable_future_imports,
853855
type_mappings=config.type_mappings,
856+
read_only_write_only_model_type=config.read_only_write_only_model_type,
854857
all_exports_scope=config.all_exports_scope,
855858
all_exports_collision_strategy=config.all_exports_collision_strategy,
856859
)

src/datamodel_code_generator/arguments.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
DataModelType,
2323
InputFileType,
2424
OpenAPIScope,
25+
ReadOnlyWriteOnlyModelType,
2526
ReuseScope,
2627
)
2728
from datamodel_code_generator.format import DatetimeClassType, Formatter, PythonVersion
@@ -653,6 +654,14 @@ def start_section(self, heading: str | None) -> None:
653654
action="store_true",
654655
default=None,
655656
)
657+
openapi_options.add_argument(
658+
"--read-only-write-only-model-type",
659+
help="Model generation for readOnly/writeOnly fields: "
660+
"'request-response' = Request/Response models only (no base model), "
661+
"'all' = Base + Request + Response models.",
662+
choices=[e.value for e in ReadOnlyWriteOnlyModelType],
663+
default=None,
664+
)
656665

657666
# ======================================================================================
658667
# General options

src/datamodel_code_generator/model/base.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from jinja2 import Environment, FileSystemLoader, Template
1919
from pydantic import Field
20+
from typing_extensions import Self
2021

2122
from datamodel_code_generator.imports import (
2223
IMPORT_ANNOTATED,
@@ -47,6 +48,7 @@
4748
ALL_MODEL: str = "#all#"
4849

4950
ConstraintsBaseT = TypeVar("ConstraintsBaseT", bound="ConstraintsBase")
51+
DataModelFieldBaseT = TypeVar("DataModelFieldBaseT", bound="DataModelFieldBase")
5052

5153

5254
class ConstraintsBase(_BaseModel):
@@ -133,6 +135,8 @@ class Config:
133135
_pass_fields: ClassVar[set[str]] = {"parent", "data_type"}
134136
can_have_extra_keys: ClassVar[bool] = True
135137
type_has_null: Optional[bool] = None # noqa: UP045
138+
read_only: bool = False
139+
write_only: bool = False
136140

137141
if not TYPE_CHECKING:
138142
if not PYDANTIC_V2:
@@ -262,6 +266,15 @@ def fall_back_to_nullable(self) -> bool:
262266
"""Check if optional fields should be nullable by default."""
263267
return True
264268

269+
def copy_deep(self) -> Self:
270+
"""Create a deep copy of this field to avoid mutating the original."""
271+
copied = self.copy()
272+
copied.parent = None
273+
copied.data_type = self.data_type.copy()
274+
if self.data_type.data_types:
275+
copied.data_type.data_types = [dt.copy() for dt in self.data_type.data_types]
276+
return copied
277+
265278

266279
@lru_cache
267280
def get_template(template_file_path: Path) -> Template:
@@ -434,6 +447,18 @@ def _validate_fields(self, fields: list[DataModelFieldBase]) -> list[DataModelFi
434447
unique_fields.append(field)
435448
return unique_fields
436449

450+
def iter_all_fields(self, visited: set[str] | None = None) -> Iterator[DataModelFieldBase]:
451+
"""Yield all fields including those from base classes (parent fields first)."""
452+
if visited is None:
453+
visited = set()
454+
if self.reference.path in visited: # pragma: no cover
455+
return
456+
visited.add(self.reference.path)
457+
for base_class in self.base_classes:
458+
if base_class.reference and isinstance(base_class.reference.source, DataModel):
459+
yield from base_class.reference.source.iter_all_fields(visited)
460+
yield from self.fields
461+
437462
def set_base_class(self) -> None:
438463
"""Set up the base class for this model."""
439464
base_class = self.custom_base_class or self.BASE_CLASS

src/datamodel_code_generator/model/typed_dict.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,7 @@ def is_functional_syntax(self) -> bool:
9595
@property
9696
def all_fields(self) -> Iterator[DataModelFieldBase]:
9797
"""Iterate over all fields including inherited ones."""
98-
for base_class in self.base_classes:
99-
if base_class.reference is None: # pragma: no cover
100-
continue
101-
data_model = base_class.reference.source
102-
if not isinstance(data_model, DataModel): # pragma: no cover
103-
continue
104-
105-
if isinstance(data_model, TypedDict): # pragma: no cover
106-
yield from data_model.all_fields
107-
108-
yield from self.fields
98+
yield from self.iter_all_fields()
10999

110100
def render(self, *, class_name: str | None = None) -> str:
111101
"""Render TypedDict class with appropriate syntax."""

src/datamodel_code_generator/parser/base.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AllExportsCollisionStrategy,
2626
AllExportsScope,
2727
Error,
28+
ReadOnlyWriteOnlyModelType,
2829
ReuseScope,
2930
)
3031
from datamodel_code_generator.format import (
@@ -325,28 +326,21 @@ def title_to_class_name(title: str) -> str:
325326

326327

327328
def _find_base_classes(model: DataModel) -> list[DataModel]:
329+
"""Get direct base class DataModels."""
328330
return [b.reference.source for b in model.base_classes if b.reference and isinstance(b.reference.source, DataModel)]
329331

330332

331333
def _find_field(original_name: str, models: list[DataModel]) -> DataModelFieldBase | None:
332-
def _find_field_and_base_classes(
333-
model_: DataModel,
334-
) -> tuple[DataModelFieldBase | None, list[DataModel]]:
335-
for field_ in model_.fields:
336-
if field_.original_name == original_name:
337-
return field_, []
338-
return None, _find_base_classes(model_) # pragma: no cover
339-
334+
"""Find a field by original_name in the models and their base classes."""
340335
for model in models:
341-
field, base_models = _find_field_and_base_classes(model)
342-
if field:
343-
return field
344-
models.extend(base_models) # pragma: no cover # noqa: B909
345-
336+
for field in model.iter_all_fields(): # pragma: no cover
337+
if field.original_name == original_name:
338+
return field
346339
return None # pragma: no cover
347340

348341

349342
def _copy_data_types(data_types: list[DataType]) -> list[DataType]:
343+
"""Deep copy a list of DataType objects, preserving references."""
350344
copied_data_types: list[DataType] = []
351345
for data_type_ in data_types:
352346
if data_type_.reference:
@@ -477,6 +471,7 @@ def __init__( # noqa: PLR0913, PLR0915
477471
parent_scoped_naming: bool = False,
478472
dataclass_arguments: DataclassArguments | None = None,
479473
type_mappings: list[str] | None = None,
474+
read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None,
480475
) -> None:
481476
"""Initialize the Parser with configuration options."""
482477
self.keyword_only = keyword_only
@@ -605,6 +600,7 @@ def __init__( # noqa: PLR0913, PLR0915
605600
self.default_field_extras: dict[str, Any] | None = default_field_extras
606601
self.formatters: list[Formatter] = formatters
607602
self.type_mappings: dict[tuple[str, str], str] = Parser._parse_type_mappings(type_mappings)
603+
self.read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = read_only_write_only_model_type
608604

609605
@staticmethod
610606
def _parse_type_mappings(type_mappings: list[str] | None) -> dict[tuple[str, str], str]:

0 commit comments

Comments
 (0)