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

Issue:
Support nullable fields
There should be an option to allow nullable fields. I propose the following api:

```python
@dataclass
class Address(JsonSchemaMixin):
    company: Optional[str] = field(default=None, metadata=dict(nullable=True))
    building: str
    street: str
    ...
```

There are a couple of ways to support nullable fields in JSON schema, either:

```json
{
  "properties": {
    "company": {"type": ["string", "null"]}
  }
}
```
or:
```json
{
  "properties": {
    "company": {"oneOf": [{"type": "string"}, {"type": "null"}]}
  }
}
```
The former is not supported in Swagger / OpenAPI though. OpenAPI 3 also supports the following:
```json
{
  "properties": {
    "company": {"type": "string", "nullable": true}
  }
}
```

Pull request description:
Add type annotation for declaring nullable types
Support nullable types in json-schema and openapi 3 schema

Fixes #57

Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index 493c311..3749355 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -17,7 +17,7 @@ from dataclasses_jsonschema.field_types import (  # noqa: F401
     FieldEncoder, DateTimeFieldEncoder, UuidField, DecimalField,
     IPv4AddressField, IPv6AddressField, DateTimeField
 )
-from dataclasses_jsonschema.type_defs import JsonDict, SchemaType, JsonSchemaMeta  # noqa: F401
+from dataclasses_jsonschema.type_defs import JsonDict, SchemaType, JsonSchemaMeta, _NULL_TYPE, NULL  # noqa: F401
 
 try:
     import fastjsonschema
@@ -80,6 +80,13 @@ def is_literal(field: Any) -> bool:
         return False
 
 
+def is_nullable(field: Any) -> bool:
+    try:
+        return field.__origin__ == Union and _NULL_TYPE in field.__args__
+    except AttributeError:
+        return False
+
+
 def unwrap_final(final_type: Any) -> Any:
     return final_type.__args__[0] if sys.version_info[:2] >= (3, 7) else final_type.__type__
 
@@ -89,6 +96,11 @@ def unwrap_optional(optional_type: Any) -> Any:
     return Union[optional_type.__args__[:idx] + optional_type.__args__[idx+1:]]
 
 
+def unwrap_nullable(nullable_type: Any) -> Any:
+    idx = nullable_type.__args__.index(_NULL_TYPE)
+    return Union[nullable_type.__args__[:idx] + nullable_type.__args__[idx+1:]]
+
+
 def schema_reference(schema_type: SchemaType, schema_name: str) -> Dict[str, str]:
     ref_path = '#/components/schemas' if schema_type == SchemaType.SWAGGER_V3 else '#/definitions'
     return {"$ref": '{}/{}'.format(ref_path, schema_name)}
@@ -199,6 +211,8 @@ class JsonSchemaMixin:
                 def encoder(ft, v, __): return cls._field_encoders[ft].to_wire(v)
             elif is_optional(field_type):
                 def encoder(ft, val, o): return cls._encode_field(unwrap_optional(ft), val, o)
+            elif is_nullable(field_type):
+                def encoder(ft, val, o): return cls._encode_field(unwrap_nullable(ft), val, o)
             elif is_final(field_type):
                 def encoder(ft, val, o): return cls._encode_field(unwrap_final(ft), val, o)
             elif is_enum(field_type):
@@ -278,6 +292,8 @@ class JsonSchemaMixin:
             value = self._encode_field(field.type, getattr(self, field.name), omit_none)
             if omit_none and value is None:
                 continue
+            if value is NULL:
+                value = None
             data[target_field] = value
         if validate:
             self._validate(data)
@@ -286,7 +302,7 @@ class JsonSchemaMixin:
     @classmethod
     def _decode_field(cls, field: str, field_type: Any, value: Any) -> Any:
         if value is None:
-            return None
+            return NULL if is_nullable(field_type) else None
         decoder = None
         try:
             decoder = cls._decode_cache[field_type]  # type: ignore
@@ -300,6 +316,8 @@ class JsonSchemaMixin:
             field_type_name = cls._get_field_type_name(field_type)
             if cls._is_json_schema_subclass(field_type):
                 def decoder(_, ft, val): return ft.from_dict(val, validate=False)
+            elif is_nullable(field_type):
+                def decoder(f, ft, val): return cls._decode_field(f, unwrap_nullable(ft), val)
             elif is_optional(field_type):
                 def decoder(f, ft, val): return cls._decode_field(f, unwrap_optional(ft), val)
             elif is_final(field_type):
@@ -478,6 +496,13 @@ class JsonSchemaMixin:
             if is_optional(field_type):
                 field_schema = cls._get_field_schema(unwrap_optional(field_type), schema_type)[0]
                 required = False
+            elif is_nullable(field_type):
+                field_schema, required = cls._get_field_schema(unwrap_nullable(field_type), schema_type)
+                print(field_schema)
+                if schema_type == SchemaType.OPENAPI_3:
+                    field_schema["nullable"] = True
+                elif schema_type != SchemaType.SWAGGER_V2:
+                    field_schema = {"oneOf": [field_schema, {"type": "null"}]}
             elif is_final(field_type):
                 field_schema, required = cls._get_field_schema(unwrap_final(field_type), schema_type)
             elif is_literal(field_type):
diff --git a/dataclasses_jsonschema/type_defs.py b/dataclasses_jsonschema/type_defs.py
index 4bbe6fc..ff11395 100644
--- a/dataclasses_jsonschema/type_defs.py
+++ b/dataclasses_jsonschema/type_defs.py
@@ -1,5 +1,5 @@
 from enum import Enum
-from typing import Union, Dict, Any, List
+from typing import Union, Dict, Any, List, Type
 
 try:
     # Supported in future python versions
@@ -40,3 +40,25 @@ class SchemaType(Enum):
 
 # Retained for backwards compatibility
 SwaggerSpecVersion = SchemaType
+
+
+class _NULL_TYPE:
+    """Sentinel value to represent null json values for nullable fields, to distinguish them from `None`
+    for omitted fields.
+    """
+    pass
+
+
+NULL = _NULL_TYPE()
+
+
+class _Nullable:
+    """Nullable type annotation. Used to declare fields which can have a null value.
+    This is distinct from `None`, which refers to the absence of a value.
+    """
+
+    def __getitem__(self, item: Type):
+        return Union[item, _NULL_TYPE]
+
+
+Nullable = _Nullable()


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 8df892a..4e6e817 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -6,13 +6,13 @@ from typing import List, NewType, Optional, Union
 from typing_extensions import Final, Literal
 from uuid import UUID
 
+from dataclasses_jsonschema.type_defs import Nullable, NULL
 from .conftest import Foo, Point, Recursive, OpaqueData, ShoppingCart, Product, ProductList, SubSchemas, Bar, Weekday, \
     JsonSchemaMixin, Zoo, Baz
 import pytest
 
 from dataclasses_jsonschema import SchemaType, ValidationError, DecimalField, JsonSchemaMeta
 
-
 FOO_SCHEMA = {
     'description': 'A foo that foos',
     'properties': {
@@ -540,3 +540,39 @@ def test_optional_union():
         }
     })
     assert Baz.json_schema() == expected_schema
+
+
+def test_nullable_field():
+    @dataclass
+    class Employee(JsonSchemaMixin):
+        """An employee"""
+        name: str
+        manager: Nullable[Optional[str]] = None
+
+    expected_openapi_3_schema = {
+        "type": "object",
+        "description": "An employee",
+        "properties": {
+            "name": {"type": "string"},
+            "manager": {"type": "string", "nullable": True}
+        },
+        "required": ["name"]
+    }
+    expected_json_schema = compose_schema({
+        "type": "object",
+        "description": "An employee",
+        "properties": {
+            "name": {"type": "string"},
+            "manager": {"oneOf": [{"type": "string"}, {"type": "null"}]}
+        },
+        "required": ["name"]
+    })
+    assert Employee.json_schema() == expected_json_schema
+    assert (
+        Employee.json_schema(embeddable=True, schema_type=SchemaType.OPENAPI_3)["Employee"] == expected_openapi_3_schema
+    )
+
+    expected_obj = Employee(name="Joe Bloggs", manager=NULL)
+    expected_dict = {"name": "Joe Bloggs", "manager": None}
+    assert Employee.from_dict(expected_dict) == expected_obj
+    assert expected_dict == expected_obj.to_dict()