From 92cfe8c903fb23f511ea42ee1c80474b78d6256a Mon Sep 17 00:00:00 2001 From: Steve Pike Date: Tue, 18 Jul 2017 14:26:38 +0100 Subject: [PATCH] big rework to incorporate named defs and therefore support recursive defs. --- .gitignore | 2 + marshmallow_jsonschema/base.py | 69 ++++++++++++++++++++++------- tests/test_dump.py | 79 +++++++++++++++++++++------------- 3 files changed, 105 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 113294a..1e36ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.sw[op] + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/marshmallow_jsonschema/base.py b/marshmallow_jsonschema/base.py index 1bf1611..34a99c8 100644 --- a/marshmallow_jsonschema/base.py +++ b/marshmallow_jsonschema/base.py @@ -5,6 +5,7 @@ from marshmallow import fields, missing, Schema, validate from marshmallow.class_registry import get_class from marshmallow.compat import text_type, binary_type, basestring +from marshmallow.decorators import post_dump from .validation import handle_length, handle_one_of, handle_range @@ -77,12 +78,19 @@ class JSONSchema(Schema): + """Converts to JSONSchema as defined by http://json-schema.org/.""" + properties = fields.Method('get_properties') type = fields.Constant('object') required = fields.Method('get_required') - @classmethod - def _get_default_mapping(cls, obj): + def __init__(self, *args, **kwargs): + """Setup internal cache of nested fields, to prevent recursion.""" + self._nested_schema_classes = {} + self.nested = kwargs.pop('nested', False) + super(JSONSchema, self).__init__(*args, **kwargs) + + def _get_default_mapping(self, obj): """Return default mapping if there are no special needs.""" mapping = {v: k for k, v in obj.TYPE_MAPPING.items()} mapping.update({ @@ -96,11 +104,11 @@ def _get_default_mapping(cls, obj): return mapping def get_properties(self, obj): - mapping = self.__class__._get_default_mapping(obj) + mapping = self._get_default_mapping(obj) properties = {} for field_name, field in sorted(obj.fields.items()): - schema = self.__class__._get_schema(obj, field) + schema = self._get_schema_for_field(obj, field) properties[field.name] = schema return properties @@ -114,8 +122,8 @@ def get_required(self, obj): return required - @classmethod - def _from_python_type(cls, obj, field, pytype): + def _from_python_type(self, obj, field, pytype): + """Get schema definition from python type.""" json_schema = { 'title': field.attribute or field.name, } @@ -138,21 +146,22 @@ def _from_python_type(cls, obj, field, pytype): json_schema['title'] = field.metadata['metadata'].get('title') if isinstance(field, fields.List): - json_schema['items'] = cls._get_schema(obj, field.container) + json_schema['items'] = self._get_schema_for_field( + obj, field.container + ) return json_schema - @classmethod - def _get_schema(cls, obj, field): + def _get_schema_for_field(self, obj, field): """Get schema and validators for field.""" - mapping = cls._get_default_mapping(obj) + mapping = self._get_default_mapping(obj) if hasattr(field, '_jsonschema_type_mapping'): schema = field._jsonschema_type_mapping() elif field.__class__ in mapping: pytype = mapping[field.__class__] if isinstance(pytype, basestring): - schema = getattr(cls, pytype)(obj, field) + schema = getattr(self, pytype)(obj, field) else: - schema = cls._from_python_type( + schema = self._from_python_type( obj, field, pytype ) else: @@ -166,14 +175,23 @@ def _get_schema(cls, obj, field): ) return schema - - @classmethod - def _from_nested_schema(cls, obj, field): + def _from_nested_schema(self, obj, field): if isinstance(field.nested, basestring): nested = get_class(field.nested) else: nested = field.nested - schema = cls().dump(nested()).data + + name = nested.__name__ + outer_name = obj.__class__.__name__ + if name not in self._nested_schema_classes and name != outer_name: + self._nested_schema_classes[name] = JSONSchema(nested=True).dump( + nested() + ).data + + schema = { + 'type': 'object', + '$ref': '#/definitions/{}'.format(name) + } if field.metadata.get('metadata', {}).get('description'): schema['description'] = ( @@ -190,3 +208,22 @@ def _from_nested_schema(cls, obj, field): } return schema + + def dump(self, obj, **kwargs): + """Take obj for class name.""" + self.obj = obj + return super(JSONSchema, self).dump(obj, **kwargs) + + @post_dump(pass_many=False) + def wrap(self, data): + """Wrap this with the root schema definitions.""" + if self.nested: # no need to wrap, will be in outer defs + return data + + name = self.obj.__class__.__name__ + self._nested_schema_classes[name] = data + root = { + 'definitions': self._nested_schema_classes, + '$ref': '#/definitions/{}'.format(name) + } + return root diff --git a/tests/test_dump.py b/tests/test_dump.py index 78d0150..1bd73ab 100644 --- a/tests/test_dump.py +++ b/tests/test_dump.py @@ -18,15 +18,17 @@ def test_dump_schema(): dumped = json_schema.dump(schema).data _validate_schema(dumped) assert len(schema.fields) > 1 + props = dumped['definitions']['UserSchema']['properties'] for field_name, field in schema.fields.items(): - assert field_name in dumped['properties'] + assert field_name in props def test_default(): schema = UserSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['id']['default'] == 'no-id' + props = dumped['definitions']['UserSchema']['properties'] + assert props['id']['default'] == 'no-id' def test_descriptions(): class TestSchema(Schema): @@ -36,7 +38,8 @@ class TestSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['myfield']['description'] == 'Brown Cow' + props = dumped['definitions']['TestSchema']['properties'] + assert props['myfield']['description'] == 'Brown Cow' def test_nested_descriptions(): class TestSchema(Schema): @@ -51,8 +54,11 @@ class TestNestedSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - nested_dmp = dumped['properties']['nested'] - assert nested_dmp['properties']['myfield']['description'] == 'Brown Cow' + nested_def = dumped['definitions']['TestSchema'] + nested_dmp = dumped['definitions']['TestNestedSchema']['properties']['nested'] + assert nested_def['properties']['myfield']['description'] == 'Brown Cow' + + assert nested_dmp['$ref'] == '#/definitions/TestSchema' assert nested_dmp['description'] == 'Nested 1' assert nested_dmp['title'] == 'Title1' @@ -68,9 +74,10 @@ class TestNestedSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - nested_json = dumped['properties']['nested'] - assert nested_json['properties']['foo']['format'] == 'integer' - assert nested_json['type'] == 'object' + nested_def = dumped['definitions']['TestSchema'] + nested_dmp = dumped['definitions']['TestNestedSchema']['properties']['nested'] + assert nested_dmp['type'] == 'object' + assert nested_def['properties']['foo']['format'] == 'integer' def test_list(): @@ -81,7 +88,7 @@ class ListSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - nested_json = dumped['properties']['foo'] + nested_json = dumped['definitions']['ListSchema']['properties']['foo'] assert nested_json['type'] == 'array' assert 'items' in nested_json item_schema = nested_json['items'] @@ -101,11 +108,11 @@ class ListSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - nested_json = dumped['properties']['bar'] + nested_json = dumped['definitions']['ListSchema']['properties']['bar'] assert nested_json['type'] == 'array' assert 'items' in nested_json item_schema = nested_json['items'] - assert 'foo' in item_schema['properties'] + assert 'InnerSchema' in item_schema['$ref'] def test_nested_recursive(): @@ -119,6 +126,8 @@ class RecursiveSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) + props = dumped['definitions']['RecursiveSchema']['properties'] + assert 'RecursiveSchema' in props['children']['items']['$ref'] def test_one_of_validator(): @@ -126,7 +135,11 @@ def test_one_of_validator(): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['sex']['enum'] == ['male', 'female'] + assert ( + dumped['definitions']['UserSchema']['properties']['sex']['enum'] == [ + 'male', 'female' + ] + ) def test_range_validator(): @@ -134,20 +147,22 @@ def test_range_validator(): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['floor']['minimum'] == 1 - assert dumped['properties']['floor']['maximum'] == 4 + props = dumped['definitions']['Address']['properties'] + assert props['floor']['minimum'] == 1 + assert props['floor']['maximum'] == 4 def test_length_validator(): schema = UserSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['name']['minLength'] == 1 - assert dumped['properties']['name']['maxLength'] == 255 - assert dumped['properties']['addresses']['minItems'] == 1 - assert dumped['properties']['addresses']['maxItems'] == 3 - assert dumped['properties']['const']['minLength'] == 50 - assert dumped['properties']['const']['maxLength'] == 50 + props = dumped['definitions']['UserSchema']['properties'] + assert props['name']['minLength'] == 1 + assert props['name']['maxLength'] == 255 + assert props['addresses']['minItems'] == 1 + assert props['addresses']['maxItems'] == 3 + assert props['const']['minLength'] == 50 + assert props['const']['maxLength'] == 50 def test_length_validator_value_error(): class BadSchema(Schema): @@ -184,12 +199,12 @@ class SchemaNoMin(Schema): schema1 = SchemaMin(strict=True) schema2 = SchemaNoMin(strict=True) json_schema = JSONSchema() - dumped1 = json_schema.dump(schema1) - dumped2 = json_schema.dump(schema2) - dumped1.data['properties']['floor']['minimum'] == 1 - dumped1.data['properties']['floor']['exclusiveMinimum'] is True - dumped2.data['properties']['floor']['minimum'] == 0 - dumped2.data['properties']['floor']['exclusiveMinimum'] is False + dumped1 = json_schema.dump(schema1).data['definitions']['SchemaMin'] + dumped2 = json_schema.dump(schema2).data['definitions']['SchemaNoMin'] + dumped1['properties']['floor']['minimum'] == 1 + dumped1['properties']['floor']['exclusiveMinimum'] is True + dumped2['properties']['floor']['minimum'] == 0 + dumped2['properties']['floor']['exclusiveMinimum'] is False def test_title(): @@ -200,7 +215,9 @@ class TestSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data _validate_schema(dumped) - assert dumped['properties']['myfield']['title'] == 'Brown Cowzz' + assert dumped['definitions']['TestSchema']['properties']['myfield'][ + 'title' + ] == 'Brown Cowzz' def test_unknown_typed_field_throws_valueerror(): @@ -239,7 +256,9 @@ class UserSchema(Schema): schema = UserSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data - assert dumped['properties']['favourite_colour'] == {'type': 'string'} + assert dumped['definitions']['UserSchema']['properties'][ + 'favourite_colour' + ] == {'type': 'string'} def test_readonly(): @@ -250,7 +269,9 @@ class TestSchema(Schema): schema = TestSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data - assert dumped['properties']['readonly_fld'] == { + assert dumped['definitions']['TestSchema']['properties'][ + 'readonly_fld' + ] == { 'title': 'readonly_fld', 'type': 'string', 'readonly': True,