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

Issue:
Support dataclass inheritance using allOf
The `allOf` keyword can be used to support composition. So from the example given in the swagger docs:

```python
@dataclass
class Pet(JsonSchemaMixin):
    name: str

@dataclass
class Cat(Pet):
    hunting_skill: HuntingSkill
``` 

would generate the following schema:

```yaml
definitions:
  Pet:
    type: object
    properties:
      name:
        type: string
    required:
    - name
  Cat:
    description: A representation of a cat
    allOf:
    - $ref: '#/definitions/Pet'
    - type: object
      properties:
        hunting_skill:
          type: string
          enum:
...
```

**Note:** I don't plan to support the `discriminator` keyword when generating swagger schemas. 

Pull request:
Support dataclass inheritance with 'allOf'
Fixes #40

Patch:
diff --git a/dataclasses_jsonschema/__init__.py b/dataclasses_jsonschema/__init__.py
index ee49fdf..383d591 100644
--- a/dataclasses_jsonschema/__init__.py
+++ b/dataclasses_jsonschema/__init__.py
@@ -84,6 +84,11 @@ def final_wrapped_type(final_type: Any) -> Any:
     return final_type.__args__[0] if sys.version_info[:2] >= (3, 7) else final_type.__type__
 
 
+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)}
+
+
 _ValueEncoder = Callable[[Any, Any, bool], Any]
 _ValueDecoder = Callable[[str, Any, Any], Any]
 
@@ -230,18 +235,32 @@ class JsonSchemaMixin:
         return encoder(field_type, value, omit_none)
 
     @classmethod
-    def _get_fields(cls) -> List[Tuple[Field, str]]:
-        if cls._mapped_fields is None:
+    def _get_fields(cls, base_fields=True) -> List[Tuple[Field, str]]:
+
+        def _get_fields_uncached():
+            dataclass_bases = [
+                klass for klass in cls.__bases__ if is_dataclass(klass) and issubclass(klass, JsonSchemaMixin)
+            ]
+            base_field_names = set()
+            for base in dataclass_bases:
+                base_field_names |= {f.name for f in fields(base)}
+
             mapped_fields = []
             type_hints = get_type_hints(cls)
             for f in fields(cls):
                 # Skip internal fields
-                if f.name.startswith("_"):
+                if f.name.startswith("_") or (not base_fields and f.name in base_field_names):
                     continue
                 # Note fields() doesn't resolve forward refs
                 f.type = type_hints[f.name]
                 mapped_fields.append((f, cls.field_mapping().get(f.name, f.name)))
-            cls._mapped_fields = mapped_fields  # type: ignore
+            return mapped_fields
+
+        if not base_fields:
+            return _get_fields_uncached()
+
+        if cls._mapped_fields is None:
+            cls._mapped_fields = _get_fields_uncached()
         return cls._mapped_fields  # type: ignore
 
     def to_dict(self, omit_none: bool = True, validate: bool = False) -> JsonDict:
@@ -447,9 +466,8 @@ class JsonSchemaMixin:
             field_meta = FieldMeta(schema_type=schema_type)
 
         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):
-            field_schema = {'$ref': '{}/{}'.format(ref_path, field_type_name)}
+            field_schema = schema_reference(schema_type, field_type_name)
         else:
             # If is optional[...]
             if is_optional(field_type):
@@ -581,7 +599,7 @@ class JsonSchemaMixin:
         else:
             properties = {}
             required = []
-            for field, target_field in cls._get_fields():
+            for field, target_field in cls._get_fields(base_fields=False):
                 properties[target_field], is_required = cls._get_field_schema(field, schema_type)
                 cls._get_field_definitions(field.type, definitions, schema_type)
                 if is_required:
@@ -595,6 +613,17 @@ class JsonSchemaMixin:
             # Needed for Draft 04 backwards compatibility
             if len(required) == 0:
                 del schema["required"]
+
+            dataclass_bases = [
+                klass for klass in cls.__bases__ if is_dataclass(klass) and issubclass(klass, JsonSchemaMixin)
+            ]
+            if len(dataclass_bases) > 0:
+                schema = {
+                    "allOf": [schema_reference(schema_type, base.__name__) for base in dataclass_bases] + [schema]
+                }
+                for base in dataclass_bases:
+                    definitions.update(base.json_schema(embeddable=True, schema_type=schema_type))
+
             if cls.__doc__:
                 schema['description'] = cls.__doc__
 


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 85a6068..b341235 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -492,3 +492,35 @@ def test_serialise_deserialise_opaque_data():
     dict_data = {"a": ["foo", 123], "b": {"foo": "bar", "baz": 123}}
     assert data.to_dict() == dict_data
     assert data == OpaqueData.from_dict(dict_data)
+
+
+def test_inherited_schema():
+    @dataclass
+    class Pet(JsonSchemaMixin):
+        """A generic pet"""
+        name: str
+
+    @dataclass
+    class Cat(Pet):
+        """A cat"""
+        hunting_skill: str
+
+    expected_schema = compose_schema({
+        "description": "A cat",
+        "allOf": [
+            {"$ref": "#/definitions/Pet"},
+            {
+                "type": "object",
+                "properties": {"hunting_skill": {"type": "string"}},
+                "required": ["hunting_skill"]
+            },
+        ]
+    }, {
+        "Pet": {
+            "description": "A generic pet",
+            "type": "object",
+            "properties": {"name": {"type": "string"}},
+            "required": ["name"]
+        }
+    })
+    assert Cat.json_schema() == expected_schema