diff --git a/.travis.yml b/.travis.yml index 51ab910..4e5b910 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,7 @@ before_install: - pip install -U pip install: - - pip install -U .[reco] - - pip install strict-rfc3339 jsonschema coveralls + - make installcheck script: coverage run --source marshmallow_jsonschema -m py.test after_success: coveralls diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..1efdab0 --- /dev/null +++ b/CHANGES @@ -0,0 +1,5 @@ +0.3.0 (2016-06-12) + - add support for marshmallow validators (see #14) + +0.2.0 (2016-05-25) + - add support for titles & descriptions in metadata diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2605c39 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ + + +installcheck: + pip install -U .[reco] + pip install strict-rfc3339 jsonschema coveralls coverage + +check: + py.test -v + +coverage: + coverage erase + coverage run --source marshmallow_jsonschema -m py.test -v + coverage report -m + +pypitest: + python setup.py sdist upload -r pypitest + +pypi: + python setup.py sdist upload -r pypi diff --git a/marshmallow_jsonschema/base.py b/marshmallow_jsonschema/base.py index 15dcbce..1f081dc 100644 --- a/marshmallow_jsonschema/base.py +++ b/marshmallow_jsonschema/base.py @@ -3,8 +3,11 @@ import decimal from collections import OrderedDict -from marshmallow import fields, missing, Schema -from marshmallow.compat import text_type, binary_type +from marshmallow import fields, missing, Schema, validate +from marshmallow.class_registry import get_class +from marshmallow.compat import text_type, binary_type, basestring + +from .validation import handle_length, handle_one_of, handle_range __all__ = ['JSONSchema'] @@ -67,6 +70,13 @@ } +FIELD_VALIDATORS = { + validate.Length: handle_length, + validate.OneOf: handle_one_of, + validate.Range: handle_range, +} + + class JSONSchema(Schema): properties = fields.Method('get_properties') type = fields.Constant('object') @@ -88,41 +98,76 @@ def get_properties(self, obj): schema = field._jsonschema_type_mapping() elif field.__class__ in mapping: pytype = mapping[field.__class__] - schema = _from_python_type(field, pytype) + schema = self.__class__._from_python_type(field, pytype) elif isinstance(field, fields.Nested): - schema = _from_nested_schema(field) + schema = self.__class__._from_nested_schema(field) else: raise ValueError('unsupported field type %s' % field) if obj.ordered: schema['propertyOrder'] = count + 1 + + # Apply any and all validators that field may have + for validator in field.validators: + if validator.__class__ in FIELD_VALIDATORS: + schema = FIELD_VALIDATORS[validator.__class__]( + schema, field, validator, obj + ) + properties[field.name] = schema + return properties def get_required(self, obj): required = [] + for field_name, field in sorted(obj.fields.items()): if field.required: required.append(field.name) + return required + @classmethod + def _from_python_type(cls, field, pytype): + json_schema = { + 'title': field.attribute or field.name, + } -def _from_python_type(field, pytype): - json_schema = { - 'title': field.attribute or field.name, - } - for key, val in TYPE_MAP[pytype].items(): - json_schema[key] = val - if field.default is not missing: - json_schema['default'] = field.default + for key, val in TYPE_MAP[pytype].items(): + json_schema[key] = val - return json_schema + if field.default is not missing: + json_schema['default'] = field.default + if field.metadata.get('metadata', {}).get('description'): + json_schema['description'] = ( + field.metadata['metadata'].get('description') + ) -def _from_nested_schema(field): - schema = JSONSchema().dump(field.nested()).data - if field.many: - schema = { - 'type': ["array"] if field.required else ['array', 'null'], - 'items': schema - } - return schema + if field.metadata.get('metadata', {}).get('title'): + json_schema['title'] = field.metadata['metadata'].get('title') + + return json_schema + + @classmethod + def _from_nested_schema(cls, field): + if isinstance(field.nested, basestring): + nested = get_class(field.nested) + else: + nested = field.nested + schema = cls().dump(nested()).data + + if field.metadata.get('metadata', {}).get('description'): + schema['description'] = ( + field.metadata['metadata'].get('description') + ) + + if field.metadata.get('metadata', {}).get('title'): + schema['title'] = field.metadata['metadata'].get('title') + + if field.many: + schema = { + 'type': ["array"] if field.required else ['array', 'null'], + 'items': schema, + } + + return schema diff --git a/marshmallow_jsonschema/validation.py b/marshmallow_jsonschema/validation.py new file mode 100644 index 0000000..4a40b61 --- /dev/null +++ b/marshmallow_jsonschema/validation.py @@ -0,0 +1,106 @@ +from marshmallow import fields + + +def handle_length(schema, field, validator, parent_schema): + """Adds validation logic for ``marshmallow.validate.Length``, setting the + values appropriately for ``fields.List``, ``fields.Nested``, and + ``fields.String``. + + Args: + schema (dict): The original JSON schema we generated. This is what we + want to post-process. + field (fields.Field): The field that generated the original schema and + who this post-processor belongs to. + validator (marshmallow.validate.Length): The validator attached to the + passed in field. + parent_schema (marshmallow.Schema): The Schema instance that the field + belongs to. + + Returns: + dict: A, possibly, new JSON Schema that has been post processed and + altered. + + Raises: + ValueError: Raised if the `field` is something other than + `fields.List`, `fields.Nested`, or `fields.String` + """ + if isinstance(field, fields.String): + minKey = 'minLength' + maxKey = 'maxLength' + elif isinstance(field, (fields.List, fields.Nested)): + minKey = 'minItems' + maxKey = 'maxItems' + else: + raise ValueError("In order to set the Length validator for JSON " + "schema, the field must be either a List or a String") + + if validator.min: + schema[minKey] = validator.min + + if validator.max: + schema[maxKey] = validator.max + + if validator.equal: + schema[minKey] = validator.equal + schema[maxKey] = validator.equal + + return schema + + +def handle_one_of(schema, field, validator, parent_schema): + """Adds the validation logic for ``marshmallow.validate.OneOf`` by setting + the JSONSchema `enum` property to the allowed choices in the validator. + + Args: + schema (dict): The original JSON schema we generated. This is what we + want to post-process. + field (fields.Field): The field that generated the original schema and + who this post-processor belongs to. + validator (marshmallow.validate.OneOf): The validator attached to the + passed in field. + parent_schema (marshmallow.Schema): The Schema instance that the field + belongs to. + + Returns: + dict: A, possibly, new JSON Schema that has been post processed and + altered. + """ + if validator.choices: + schema['enum'] = validator.choices + + return schema + + +def handle_range(schema, field, validator, parent_schema): + """Adds validation logic for ``marshmallow.validate.Range``, setting the + values appropriately ``fields.Number`` and it's subclasses. + + Args: + schema (dict): The original JSON schema we generated. This is what we + want to post-process. + field (fields.Field): The field that generated the original schema and + who this post-processor belongs to. + validator (marshmallow.validate.Length): The validator attached to the + passed in field. + parent_schema (marshmallow.Schema): The Schema instance that the field + belongs to. + + Returns: + dict: A, possibly, new JSON Schema that has been post processed and + altered. + """ + if not isinstance(field, fields.Number): + return schema + + if validator.min: + schema['minimum'] = validator.min + schema['exclusiveMinimum'] = True + else: + schema['minimum'] = 0 + schema['exclusiveMinimum'] = False + + if validator.max: + schema['maximum'] = validator.max + schema['exclusiveMaximum'] = True + + return schema diff --git a/setup.py b/setup.py index bb07e1c..6d828ac 100644 --- a/setup.py +++ b/setup.py @@ -8,12 +8,12 @@ def read(fname): try: import pypandoc long_description = pypandoc.convert('README.md', 'rst') -except (IOError, ImportError): +except (IOError, ImportError, OSError): long_description = read('README.md') setup( name='marshmallow-jsonschema', - version='0.1.7', + version='0.3.0', description='JSON Schema Draft v4 (http://json-schema.org/) formatting with marshmallow', long_description=long_description, author='Stephen Fuhry', @@ -22,8 +22,8 @@ def read(fname): packages=find_packages(exclude=("test*", )), package_dir={'marshmallow-jsonschema': 'marshmallow-jsonschema'}, include_package_data=True, - install_requires=['marshmallow>=2.3.0'], - tests_require=['pytest>=2.1', 'jsonschema', 'strict-rfc3339'], + install_requires=['marshmallow>=2.9.0'], + tests_require=['pytest>=2.9.2', 'jsonschema', 'strict-rfc3339', 'coverage>=4.1'], license=read('LICENSE'), zip_safe=False, keywords=('marshmallow-jsonschema marshmallow schema serialization ' diff --git a/tests/__init__.py b/tests/__init__.py index 84e99e9..cdaae84 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,7 +8,7 @@ class Address(Schema): street = fields.String(required=True) number = fields.String(required=True) city = fields.String(required=True) - floor = fields.String() + floor = fields.Integer(validate=validate.Range(min=1, max=4)) class GithubProfile(Schema): @@ -16,7 +16,8 @@ class GithubProfile(Schema): class UserSchema(Schema): - name = fields.String(required=True) + name = fields.String(required=True, + validate=validate.Length(min=1, max=255)) age = fields.Float() created = fields.DateTime() created_formatted = fields.DateTime(format="%Y-%m-%d", attribute="created") @@ -38,8 +39,10 @@ class UserSchema(Schema): since_created = fields.TimeDelta() sex = fields.Str(validate=validate.OneOf(['male', 'female'])) various_data = fields.Dict() - addresses = fields.Nested(Address, many=True) + addresses = fields.Nested(Address, many=True, + validate=validate.Length(min=1, max=3)) github = fields.Nested(GithubProfile) + const = fields.String(validate=validate.Length(equal=50)) class BaseTest(unittest.TestCase): diff --git a/tests/test_dump.py b/tests/test_dump.py index af229a5..dd87322 100644 --- a/tests/test_dump.py +++ b/tests/test_dump.py @@ -1,89 +1,214 @@ -from marshmallow import Schema, fields +from marshmallow import Schema, fields, validate from marshmallow_jsonschema import JSONSchema from jsonschema import Draft4Validator - -from . import BaseTest, UserSchema - - -class TestDumpSchema(BaseTest): - - def _validate_schema(self, schema): - ''' - raises jsonschema.exceptions.SchemaError - ''' - Draft4Validator.check_schema(schema) - - def test_dump_schema(self): - schema = UserSchema() - json_schema = JSONSchema() - dumped = json_schema.dump(schema).data - self._validate_schema(dumped) - self.assertGreater(len(schema.fields), 1) - for field_name, field in schema.fields.items(): - self.assertIn(field_name, dumped['properties']) - - def test_default(self): - schema = UserSchema() - json_schema = JSONSchema() - dumped = json_schema.dump(schema).data - self._validate_schema(dumped) - self.assertEqual(dumped['properties']['id']['default'], 'no-id') - - def test_unknown_typed_field_throws_valueerror(self): - - class Invalid(fields.Field): - def _serialize(self, value, attr, obj): - return value - - class UserSchema(Schema): - favourite_colour = Invalid() - - schema = UserSchema() - json_schema = JSONSchema() - with self.assertRaises(ValueError): - dumped = json_schema.dump(schema).data - - def test_unknown_typed_field(self): - - class Colour(fields.Field): - - def _jsonschema_type_mapping(self): - return { - 'type': 'string', - } - - def _serialize(self, value, attr, obj): - r, g, b = value - r = hex(r)[2:] - g = hex(g)[2:] - b = hex(b)[2:] - return '#' + r + g + b - - class UserSchema(Schema): - name = fields.String(required=True) - favourite_colour = Colour() - - schema = UserSchema() - json_schema = JSONSchema() - dumped = json_schema.dump(schema).data - self.assertEqual(dumped['properties']['favourite_colour'], - {'type': 'string'}) - - def test_property_order(self): - - class UserSchema(Schema): - first = fields.String() - second = fields.String() - third = fields.String() - fourth = fields.String() - - class Meta: - ordered = True - - schema = UserSchema() - json_schema = JSONSchema() - dumped = json_schema.dump(schema).data - self.assertEqual(dumped['properties']['first']['propertyOrder'], 1) - self.assertEqual(dumped['properties']['second']['propertyOrder'], 2) - self.assertEqual(dumped['properties']['third']['propertyOrder'], 3) - self.assertEqual(dumped['properties']['fourth']['propertyOrder'], 4) +import pytest + +from . import BaseTest, UserSchema, Address + + +def _validate_schema(schema): + ''' + raises jsonschema.exceptions.SchemaError + ''' + Draft4Validator.check_schema(schema) + +def test_dump_schema(): + schema = UserSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + _validate_schema(dumped) + assert len(schema.fields) > 1 + for field_name, field in schema.fields.items(): + assert field_name in dumped['properties'] + +def test_default(): + schema = UserSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + _validate_schema(dumped) + assert dumped['properties']['id']['default'] == 'no-id' + +def test_descriptions(): + class TestSchema(Schema): + myfield = fields.String(metadata={'description': 'Brown Cow'}) + yourfield = fields.Integer(required=True) + schema = TestSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + _validate_schema(dumped) + assert dumped['properties']['myfield']['description'] == 'Brown Cow' + +def test_nested_descriptions(): + class TestSchema(Schema): + myfield = fields.String(metadata={'description': 'Brown Cow'}) + yourfield = fields.Integer(required=True) + class TestNestedSchema(Schema): + nested = fields.Nested( + TestSchema, metadata={'description': 'Nested 1', 'title': 'Title1'}) + yourfield_nested = fields.Integer(required=True) + + schema = TestNestedSchema() + 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' + assert nested_dmp['description'] == 'Nested 1' + assert nested_dmp['title'] == 'Title1' + + +def test_nested_string_to_cls(): + class TestSchema(Schema): + foo = fields.Integer(required=True) + + class TestNestedSchema(Schema): + foo2 = fields.Integer(required=True) + nested = fields.Nested('TestSchema') + schema = TestNestedSchema() + 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' + + +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'] + + +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 + +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 + +def test_length_validator_value_error(): + class BadSchema(Schema): + bob = fields.Integer(validate=validate.Length(min=1, max=3)) + schema = BadSchema(strict=True) + json_schema = JSONSchema() + with pytest.raises(ValueError): + json_schema.dump(schema) + + +def test_handle_range_not_number_returns_same_instance(): + class SchemaWithStringRange(Schema): + floor = fields.String(validate=validate.Range(min=1, max=4)) + class SchemaWithNoRange(Schema): + floor = fields.String() + class SchemaWithIntRangeValidate(Schema): + floor = fields.Integer(validate=validate.Range(min=1, max=4)) + class SchemaWithIntRangeNoValidate(Schema): + floor = fields.Integer() + schema1 = SchemaWithStringRange(strict=True) + schema2 = SchemaWithNoRange(strict=True) + schema3 = SchemaWithIntRangeValidate(strict=True) + schema4 = SchemaWithIntRangeNoValidate(strict=True) + json_schema = JSONSchema() + json_schema.dump(schema1) == json_schema.dump(schema2) + json_schema.dump(schema3) != json_schema.dump(schema4) + + +def test_handle_range_no_minimum(): + class SchemaMin(Schema): + floor = fields.Integer(validate=validate.Range(min=1, max=4)) + class SchemaNoMin(Schema): + floor = fields.Integer(validate=validate.Range(max=4)) + 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 + + +def test_title(): + class TestSchema(Schema): + myfield = fields.String(metadata={'title': 'Brown Cowzz'}) + yourfield = fields.Integer(required=True) + schema = TestSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + _validate_schema(dumped) + assert dumped['properties']['myfield']['title'] == 'Brown Cowzz' + +def test_unknown_typed_field_throws_valueerror(): + + class Invalid(fields.Field): + def _serialize(self, value, attr, obj): + return value + + class UserSchema(Schema): + favourite_colour = Invalid() + + schema = UserSchema() + json_schema = JSONSchema() + with pytest.raises(ValueError): + json_schema.dump(schema).data + +def test_unknown_typed_field(): + + class Colour(fields.Field): + + def _jsonschema_type_mapping(self): + return { + 'type': 'string', + } + + def _serialize(self, value, attr, obj): + r, g, b = value + r = hex(r)[2:] + g = hex(g)[2:] + b = hex(b)[2:] + return '#' + r + g + b + + class UserSchema(Schema): + name = fields.String(required=True) + favourite_colour = Colour() + + schema = UserSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + assert dumped['properties']['favourite_colour'] == {'type': 'string'} + + +def test_property_order(): + + class UserSchema(Schema): + first = fields.String() + second = fields.String() + third = fields.String() + fourth = fields.String() + + class Meta: + ordered = True + + schema = UserSchema() + json_schema = JSONSchema() + dumped = json_schema.dump(schema).data + assert dumped['properties']['first']['propertyOrder'] == 1 + assert dumped['properties']['second']['propertyOrder'] == 2 + assert dumped['properties']['third']['propertyOrder'] == 3 + assert dumped['properties']['fourth']['propertyOrder'] == 4