From 79484f58a5d463829c25978b632de4354710b833 Mon Sep 17 00:00:00 2001 From: Luke Marlin Date: Tue, 29 Oct 2019 23:51:21 +0100 Subject: [PATCH] Add support for title and description metadata - Refactor _resolve_additional_properties to reuse parts of it for title and description support - Add _get_schema_metadata that retrieves the 3 currently supported metadata options on a schema - Rename 'cls' params to something less confusing as 'cls' is a python keyword - Add documentation for title, description and additional_properties - Move addtional_properties tests to a new forlder "schema_metadata_tests" so they can be gathered in a common place Closes #41 --- README.md | 39 +++++++ marshmallow_jsonschema/base.py | 65 ++++++++--- tests/schema_metadata_tests/__init__.py | 0 .../test_additional_properties.py | 2 +- .../schema_metadata_tests/test_description.py | 102 ++++++++++++++++++ tests/schema_metadata_tests/test_title.py | 102 ++++++++++++++++++ tests/schema_metadata_tests/test_unchecked.py | 21 ++++ 7 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 tests/schema_metadata_tests/__init__.py rename tests/{ => schema_metadata_tests}/test_additional_properties.py (98%) create mode 100644 tests/schema_metadata_tests/test_description.py create mode 100644 tests/schema_metadata_tests/test_title.py create mode 100644 tests/schema_metadata_tests/test_unchecked.py diff --git a/README.md b/README.md index d59520f..4f00966 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,45 @@ if __name__ == '__main__': ### Advanced usage +#### Adding metadata on schemas +It is possible to define metadata on schema definition. +So far, three of them are supported: + * description + * title + * additional_properties (results in additionalProperties when dumped) + +To use them, you need to provide a `Meta` inner class to your `Schema` with the respective properties: + +```python +class MySchema(Schema): + class Meta: + additional_properties = True + title = "A nice title" + description = "A lengthy description" + a_field = fields.String() +``` + +#### Customizing a field +When defining a `field` in a `Schema`, it is possible to override the `field`'s JSONSchema properties using the `metadata` param. +For example, to give a description to a field: + +```python +class MySchema(Schema): + myfield = fields.String(metadata={'description': 'My description'}) +``` + +As noted before, this also allows overriding as well as providing additional properties: + +```python +>>>class MySchema(Schema): +... myfield = fields.String(metadata={'unkown_prop':'value', "type":"integer"}, required=True) +>>>JSONSchema().dump(MySchema()) +{'$schema': 'http://json-schema.org/draft-07/schema#', 'definitions': {'MySchema': {'properties': {'myfield': {'title': 'myfield', 'type': 'integer', 'unkown_prop': 'value'}}, 'required': ['myfield'], 'type': 'object', 'additionalProperties': False}}, '$ref': '#/definitions/MySchema'} +``` + +In the above example, we added some unspecified field AND we also changed the type entirely. + + #### Custom Type support Simply add a `_jsonschema_type_mapping` method to your field diff --git a/marshmallow_jsonschema/base.py b/marshmallow_jsonschema/base.py index 93961e0..0f42bb4 100644 --- a/marshmallow_jsonschema/base.py +++ b/marshmallow_jsonschema/base.py @@ -51,19 +51,56 @@ } -def _resolve_additional_properties(cls): - meta = cls.Meta +def _get_schema_metadata(schema_cls): + metadata = {} - additional_properties = getattr(meta, "additional_properties", None) - if additional_properties is not None: - if additional_properties in (True, False): - return additional_properties - else: + metadata["additionalProperties"] = \ + _resolve_additional_properties(schema_cls) + + title = _resolve_metadata(schema_cls, "title", type=str) + if title: + metadata["title"] = title + + description = _resolve_metadata(schema_cls, "description", type=str) + if description: + metadata["description"] = description + + return metadata + + +def _resolve_metadata(schema_cls, name, type=None, acceptable_values=None): + meta = schema_cls.Meta + + metadata = getattr(meta, name, None) + if metadata is None: + return metadata + + if type is not None: + if not isinstance(metadata, type): raise UnsupportedValueError( - "`additional_properties` must be either True or False" + "`{}` must be of type {}".format(name, type.__name__) ) - unknown = getattr(meta, "unknown", None) + if acceptable_values is not None: + if metadata not in acceptable_values: + raise UnsupportedValueError( + "`{}` must be one of the following values: {}".format( + name, + ", ".join([str(v) for v in acceptable_values]) + ) + ) + + return metadata + + +def _resolve_additional_properties(schema_cls): + additional_properties = _resolve_metadata( + schema_cls, "additional_properties", type=bool) + + if additional_properties is not None: + return additional_properties + + unknown = getattr(schema_cls.Meta, "unknown", None) if unknown is None: return False elif unknown in (RAISE, EXCLUDE): @@ -204,9 +241,7 @@ def _from_nested_schema(self, obj, field): wrapped_nested.dump(nested_instance) ) - wrapped_dumped["additionalProperties"] = _resolve_additional_properties( - nested_cls - ) + wrapped_dumped.update(_get_schema_metadata(nested_cls)) self._nested_schema_classes[name] = wrapped_dumped @@ -243,10 +278,10 @@ def wrap(self, data, **_): if self.nested: # no need to wrap, will be in outer defs return data - cls = self.obj.__class__ - name = cls.__name__ + schema_cls = self.obj.__class__ + name = schema_cls.__name__ - data["additionalProperties"] = _resolve_additional_properties(cls) + data.update(_get_schema_metadata(schema_cls)) self._nested_schema_classes[name] = data root = { diff --git a/tests/schema_metadata_tests/__init__.py b/tests/schema_metadata_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_additional_properties.py b/tests/schema_metadata_tests/test_additional_properties.py similarity index 98% rename from tests/test_additional_properties.py rename to tests/schema_metadata_tests/test_additional_properties.py index 47e4f04..e5bcb67 100644 --- a/tests/test_additional_properties.py +++ b/tests/schema_metadata_tests/test_additional_properties.py @@ -3,7 +3,7 @@ from marshmallow_jsonschema import UnsupportedValueError, JSONSchema from marshmallow_jsonschema.compat import RAISE, INCLUDE, EXCLUDE -from . import validate_and_dump +from .. import validate_and_dump def test_additional_properties_default(): diff --git a/tests/schema_metadata_tests/test_description.py b/tests/schema_metadata_tests/test_description.py new file mode 100644 index 0000000..8b38658 --- /dev/null +++ b/tests/schema_metadata_tests/test_description.py @@ -0,0 +1,102 @@ +import pytest +from marshmallow import Schema, fields + +from marshmallow_jsonschema import UnsupportedValueError, JSONSchema +from .. import validate_and_dump + + +class TestDescriptionSchemaMetadata: + + def test_description_default(self): + class TestSchema(Schema): + foo = fields.Integer() + + schema = TestSchema() + dumped = validate_and_dump(schema) + definition_to_test = dumped["definitions"]["TestSchema"] + assert "description" not in definition_to_test + + @pytest.mark.parametrize("description_value", ("desc1", "desc2")) + def test_description_from_meta(self, description_value): + class TestSchema(Schema): + class Meta: + description = description_value + foo = fields.Integer() + + schema = TestSchema() + dumped = validate_and_dump(schema) + + assert ( + dumped["definitions"]["TestSchema"]["description"] + == description_value + ) + + @pytest.mark.parametrize("invalid_value", [ + True, + 4, + {} + ]) + def test_description_invalid_value(self, invalid_value): + class TestSchema(Schema): + class Meta: + description = invalid_value + foo = fields.Integer() + + schema = TestSchema() + json_schema = JSONSchema() + + with pytest.raises(UnsupportedValueError): + json_schema.dump(schema) + + +class TestDescriptionNestedSchemaMetadata: + + def test_description_default(self): + class TestNestedSchema(Schema): + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + dumped = validate_and_dump(schema) + definition_to_test = dumped["definitions"]["TestNestedSchema"] + assert "description" not in definition_to_test + + @pytest.mark.parametrize("description_value", ("desc1", "desc2")) + def test_description_from_meta(self, description_value): + class TestNestedSchema(Schema): + class Meta: + description = description_value + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + dumped = validate_and_dump(schema) + + assert ( + dumped["definitions"]["TestNestedSchema"]["description"] + == description_value + ) + + @pytest.mark.parametrize("invalid_value", [ + True, + 4, + {} + ]) + def test_description_invalid_value(self, invalid_value): + class TestNestedSchema(Schema): + class Meta: + description = invalid_value + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + json_schema = JSONSchema() + + with pytest.raises(UnsupportedValueError): + json_schema.dump(schema) diff --git a/tests/schema_metadata_tests/test_title.py b/tests/schema_metadata_tests/test_title.py new file mode 100644 index 0000000..0ee8db0 --- /dev/null +++ b/tests/schema_metadata_tests/test_title.py @@ -0,0 +1,102 @@ +import pytest +from marshmallow import Schema, fields + +from marshmallow_jsonschema import UnsupportedValueError, JSONSchema +from .. import validate_and_dump + + +class TestDescriptionSchemaMetadata: + + def test_title_default(self): + class TestSchema(Schema): + foo = fields.Integer() + + schema = TestSchema() + dumped = validate_and_dump(schema) + definition_to_test = dumped["definitions"]["TestSchema"] + assert "title" not in definition_to_test + + @pytest.mark.parametrize("title_value", ("desc1", "desc2")) + def test_title_from_meta(self, title_value): + class TestSchema(Schema): + class Meta: + title = title_value + foo = fields.Integer() + + schema = TestSchema() + dumped = validate_and_dump(schema) + + assert ( + dumped["definitions"]["TestSchema"]["title"] + == title_value + ) + + @pytest.mark.parametrize("invalid_value", [ + True, + 4, + {} + ]) + def test_title_invalid_value(self, invalid_value): + class TestSchema(Schema): + class Meta: + title = invalid_value + foo = fields.Integer() + + schema = TestSchema() + json_schema = JSONSchema() + + with pytest.raises(UnsupportedValueError): + json_schema.dump(schema) + + +class TestDescriptionNestedSchemaMetadata: + + def test_title_default(self): + class TestNestedSchema(Schema): + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + dumped = validate_and_dump(schema) + definition_to_test = dumped["definitions"]["TestNestedSchema"] + assert "title" not in definition_to_test + + @pytest.mark.parametrize("title_value", ("desc1", "desc2")) + def test_title_from_meta(self, title_value): + class TestNestedSchema(Schema): + class Meta: + title = title_value + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + dumped = validate_and_dump(schema) + + assert ( + dumped["definitions"]["TestNestedSchema"]["title"] + == title_value + ) + + @pytest.mark.parametrize("invalid_value", [ + True, + 4, + {} + ]) + def test_title_invalid_value(self, invalid_value): + class TestNestedSchema(Schema): + class Meta: + title = invalid_value + foo = fields.Integer() + + class TestSchema(Schema): + nested = fields.Nested(TestNestedSchema()) + + schema = TestSchema() + json_schema = JSONSchema() + + with pytest.raises(UnsupportedValueError): + json_schema.dump(schema) diff --git a/tests/schema_metadata_tests/test_unchecked.py b/tests/schema_metadata_tests/test_unchecked.py new file mode 100644 index 0000000..2ee7209 --- /dev/null +++ b/tests/schema_metadata_tests/test_unchecked.py @@ -0,0 +1,21 @@ +import pytest +from marshmallow import Schema, fields + +from marshmallow_jsonschema import UnsupportedValueError, JSONSchema +from .. import validate_and_dump + +@pytest.mark.parametrize("unchecked_value", (False, True)) +def test_additional_properties_from_meta(unchecked_value): + + class TestSchema(Schema): + class Meta: + additional_properties = unchecked_value + + foo = fields.Integer() + + schema = TestSchema() + dumped = validate_and_dump(schema) + + definition_to_check = dumped["definitions"]["TestSchema"] + + assert "unchecked" not in definition_to_check