Repository: s-knibbs/dataclasses-jsonschema (python)

Issue:
Support `readOnly` and `writeOnly` OpenAPI 3 keywords
OpenAPI 3 supports some [additional keywords](https://swagger.io/docs/specification/data-models/keywords/#extra) for property schemas such as `readOnly` . These could be specified in the field metadata:

```python
@dataclass
class Employee(JsonSchemaMixin):
    id: int = field(metadata=dict(read_only=True), default=-1)
    name: str
    department: str
```

Pull request description:
Support the `readOnly` and `writeOnly` OpenAPI keywords
Closes #52 

Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 9dc52d5..ce37958 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -1,7 +1,8 @@
+import functools
 from typing import Optional, Type, Union, Any, Dict, cast, Tuple, List, TypeVar, get_type_hints, Callable
 import re
 from datetime import datetime
-from dataclasses import fields, is_dataclass, Field, MISSING
+from dataclasses import fields, is_dataclass, Field, MISSING, dataclass, asdict
 from uuid import UUID
 from enum import Enum
 import warnings
@@ -97,6 +98,7 @@ class SchemaType(Enum):
     V2 = "2.0"
     # Alias of SWAGGER_V3
     V3 = "3.0"
+    OPENAPI_3 = "3.0"
 
 
 # Retained for backwards compatibility
@@ -109,6 +111,28 @@ _ValueDecoder = Callable[[str, Any, Any], Any]
 T = TypeVar("T", bound='JsonSchemaMixin')
 
 
+@functools.lru_cache()
+def _to_camel_case(value: str) -> str:
+    if "_" in value:
+        parts = value.split("_")
+        return "".join([parts[0]] + [part[0].upper() + part[1:] for part in parts[1:]])
+    else:
+        return value
+
+
+@dataclass
+class FieldMeta:
+    default: Any = None
+    description: Optional[str] = None
+    # OpenAPI 3 only properties
+    read_only: Optional[bool] = None
+    write_only: Optional[bool] = None
+
+    @property
+    def as_dict(self) -> Dict:
+        return {_to_camel_case(k): v for k, v in asdict(self).items() if v is not None}
+
+
 class JsonSchemaMixin:
     """Mixin which adds methods to generate a JSON schema and
     convert to and from JSON encodable dicts with validation against the schema
@@ -313,29 +337,43 @@ class JsonSchemaMixin:
     def _is_json_schema_subclass(field_type) -> bool:
         return issubclass_safe(field_type, JsonSchemaMixin)
 
+    @classmethod
+    def _get_field_meta(cls, field: Field, schema_type: SchemaType) -> Tuple[FieldMeta, bool]:
+        required = True
+        field_meta = FieldMeta()
+        default_value = None
+        if field.default is not MISSING and field.default is not None:
+            # In case of default value given
+            default_value = field.default
+        elif field.default_factory is not MISSING and field.default_factory is not None:  # type: ignore
+            # In case of a default factory given, we call it
+            default_value = field.default_factory()  # type: ignore
+
+        if default_value is not None:
+            field_meta.default = cls._encode_field(field.type, default_value, omit_none=False)
+            required = False
+        if field.metadata is not None:
+            if "description" in field.metadata:
+                field_meta.description = field.metadata["description"]
+            if schema_type == SchemaType.OPENAPI_3:
+                field_meta.read_only = field.metadata.get("read_only")
+                if field_meta.read_only and default_value is None:
+                    raise ValueError(f"Read-only fields must have a default value")
+                field_meta.write_only = field.metadata.get("write_only")
+        return field_meta, required
+
     @classmethod
     def _get_field_schema(cls, field: Union[Field, Type], schema_type: SchemaType) -> Tuple[JsonDict, bool]:
         field_schema: JsonDict = {'type': 'object'}
         required = True
-        default = None
-        description = None
+
         if isinstance(field, Field):
             field_type = field.type
-            default_value = None
-            if field.default is not MISSING and field.default is not None:
-                # In case of default value given
-                default_value = field.default
-            elif field.default_factory is not MISSING and field.default_factory is not None:  # type: ignore
-                # In case of a default factory given, we call it
-                default_value = field.default_factory()  # type: ignore
-
-            if default_value is not None:
-                default = cls._encode_field(field.type, default_value, omit_none=False)
-                required = False
-            if field.metadata is not None and "description" in field.metadata:
-                description = field.metadata["description"]
+            field_meta, required = cls._get_field_meta(field, schema_type)
         else:
             field_type = field
+            field_meta = FieldMeta()
+
         field_type_name = cls._get_field_type_name(field_type)
         ref_path = '#/components/schemas' if schema_type == SchemaType.SWAGGER_V3 else '#/definitions'
         if cls._is_json_schema_subclass(field_type):
@@ -395,10 +433,7 @@ class JsonSchemaMixin:
             else:
                 warnings.warn(f"Unable to create schema for '{field_type_name}'")
 
-        if default is not None:
-            field_schema['default'] = default
-        if description is not None:
-            field_schema['description'] = description
+        field_schema.update(field_meta.as_dict)
 
         return field_schema, required
 


Test patch:
diff --git a/tests/test_all.py b/tests/test_all.py
index e6f2752..2d632c8 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -299,3 +299,26 @@ def test_default_factory():
     assert ClassTest().attri == ['val']
     assert ClassTest().to_dict() == {'attri': ['val']}
     assert ClassTest.from_dict({}).attri == ['val']
+
+
+def test_read_only_field():
+    @dataclass
+    class Employee(JsonSchemaMixin):
+        name: str
+        department: str
+        id: int = field(metadata=dict(read_only=True), default=-1)
+
+    schema = Employee.json_schema(schema_type=SchemaType.OPENAPI_3, embeddable=True)
+    assert schema['Employee']['properties']['id']['readOnly']
+    assert 'readOnly' not in Employee.json_schema(schema_type=SchemaType.DRAFT_06)['properties']['id']
+
+
+def test_read_only_field_no_default():
+    @dataclass
+    class Employee(JsonSchemaMixin):
+        name: str
+        department: str
+        id: int = field(metadata=dict(read_only=True))
+
+    with pytest.raises(ValueError):
+        Employee.json_schema(schema_type=SchemaType.OPENAPI_3, embeddable=True)