Skip to content

Commit

Permalink
big rework to incorporate named defs and therefore support recursive …
Browse files Browse the repository at this point in the history
…defs.
  • Loading branch information
stringfellow committed Jul 18, 2017
1 parent 6a4e63c commit 92cfe8c
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 45 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
*.sw[op]

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
69 changes: 53 additions & 16 deletions marshmallow_jsonschema/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -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,
}
Expand All @@ -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:
Expand All @@ -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'] = (
Expand All @@ -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
79 changes: 50 additions & 29 deletions tests/test_dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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'

Expand All @@ -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():
Expand All @@ -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']
Expand All @@ -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():
Expand All @@ -119,35 +126,43 @@ 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():
schema = UserSchema()
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():
schema = Address()
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):
Expand Down Expand Up @@ -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():
Expand All @@ -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():

Expand Down Expand Up @@ -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():
Expand All @@ -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,
Expand Down

0 comments on commit 92cfe8c

Please sign in to comment.