diff --git a/marshmallow_jsonschema/base.py b/marshmallow_jsonschema/base.py index beca998..fbe2230 100644 --- a/marshmallow_jsonschema/base.py +++ b/marshmallow_jsonschema/base.py @@ -10,12 +10,10 @@ from .validation import handle_length, handle_one_of, handle_range - __all__ = ( 'JSONSchema', ) - TYPE_MAP = { dict: { 'type': 'object', @@ -72,7 +70,6 @@ }, } - FIELD_VALIDATORS = { validate.Length: handle_length, validate.OneOf: handle_one_of, @@ -91,6 +88,7 @@ def __init__(self, *args, **kwargs): """Setup internal cache of nested fields, to prevent recursion.""" self._nested_schema_classes = {} self.nested = kwargs.pop('nested', False) + self.prefer_data_key = kwargs.pop('prefer_data_key', False) super(JSONSchema, self).__init__(*args, **kwargs) def _get_default_mapping(self, obj): @@ -112,7 +110,7 @@ def get_properties(self, obj): for field_name, field in sorted(obj.fields.items()): schema = self._get_schema_for_field(obj, field) - properties[field.name] = schema + properties[self._get_property_name_for_field(field)] = schema return properties @@ -122,16 +120,18 @@ def get_required(self, obj): for field_name, field in sorted(obj.fields.items()): if field.required: - required.append(field.name) + required.append( + self._get_property_name_for_field(field) + ) return required or missing def _from_python_type(self, obj, field, pytype): """Get schema definition from python type.""" json_schema = { - 'title': field.attribute or field.name, + 'title': field.attribute or self._get_property_name_for_field( + field), } - for key, val in TYPE_MAP[pytype].items(): json_schema[key] = val @@ -182,6 +182,20 @@ def _get_schema_for_field(self, obj, field): ) return schema + def _get_property_name_for_field(self, field): + """Get property name for field based on serialized object""" + name = field.name + + if self.prefer_data_key: + # Handle change in load_from / dump_to between Marshmallow + # versions 2 and 3. + if marshmallow.__version__.split('.', 1)[0] >= '3': + name = field.data_key or name + else: + name = field.load_from or field.dump_to or name + + return name + def _from_nested_schema(self, obj, field): """Support nested field.""" if isinstance(field.nested, basestring): diff --git a/setup.py b/setup.py index ad108af..a3835b8 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ def read(fname): package_dir={'marshmallow-jsonschema': 'marshmallow-jsonschema'}, include_package_data=True, install_requires=['marshmallow>=2.9.0'], - tests_require=['pytest>=2.9.2', 'jsonschema', 'strict-rfc3339', 'coverage>=4.1'], + tests_require=['pytest>=2.9.2', 'jsonschema<=2.5.1', 'strict-rfc3339', 'coverage>=4.1'], license=read('LICENSE'), zip_safe=False, keywords=('marshmallow-jsonschema marshmallow schema serialization ' diff --git a/tests/test_dump.py b/tests/test_dump.py index 52e8cce..d23d7a6 100644 --- a/tests/test_dump.py +++ b/tests/test_dump.py @@ -1,3 +1,4 @@ +import marshmallow from marshmallow import Schema, fields, validate from marshmallow_jsonschema import JSONSchema from jsonschema import Draft4Validator @@ -12,6 +13,7 @@ def _validate_schema(schema): ''' Draft4Validator.check_schema(schema) + def test_dump_schema(): schema = UserSchema() json_schema = JSONSchema() @@ -22,6 +24,7 @@ def test_dump_schema(): for field_name, field in schema.fields.items(): assert field_name in props + def test_default(): schema = UserSchema() json_schema = JSONSchema() @@ -33,9 +36,11 @@ def test_default(): def test_metadata(): """Metadata should be available in the field definition.""" + class TestSchema(Schema): myfield = fields.String(metadata={'foo': 'Bar'}) yourfield = fields.Integer(required=True, baz="waz") + schema = TestSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data @@ -54,10 +59,12 @@ class TestSchema(Schema): assert props['myfield']['foo'] == 'Bar' assert props['yourfield']['baz'] == 'waz' + 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 @@ -65,10 +72,12 @@ class TestSchema(Schema): props = dumped['definitions']['TestSchema']['properties'] assert props['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'}) @@ -94,6 +103,7 @@ class TestSchema(Schema): class TestNestedSchema(Schema): foo2 = fields.Integer(required=True) nested = fields.Nested('TestSchema') + schema = TestNestedSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data @@ -114,7 +124,7 @@ class ListSchema(Schema): _validate_schema(dumped) nested_json = dumped['definitions']['ListSchema']['properties']['foo'] assert nested_json['type'] == 'array' - assert 'items' in nested_json + assert 'items' in nested_json item_schema = nested_json['items'] assert item_schema['type'] == 'string' @@ -134,7 +144,7 @@ class ListSchema(Schema): _validate_schema(dumped) nested_json = dumped['definitions']['ListSchema']['properties']['bar'] assert nested_json['type'] == 'array' - assert 'items' in nested_json + assert 'items' in nested_json item_schema = nested_json['items'] assert 'InnerSchema' in item_schema['$ref'] @@ -167,6 +177,7 @@ class OuterSchema(Schema): def test_respect_only_for_nested_schema(): """Should ignore fields not in 'only' metadata for nested schemas.""" + class InnerRecursiveSchema(Schema): id = fields.Integer(required=True) baz = fields.String() @@ -190,6 +201,7 @@ class OuterSchema(Schema): def test_respect_exclude_for_nested_schema(): """Should ignore fields in 'exclude' metadata for nested schemas.""" + class InnerRecursiveSchema(Schema): id = fields.Integer(required=True) baz = fields.String() @@ -213,6 +225,7 @@ class OuterSchema(Schema): def test_respect_dotted_exclude_for_nested_schema(): """Should ignore dotted fields in 'exclude' metadata for nested schemas.""" + class InnerRecursiveSchema(Schema): id = fields.Integer(required=True) baz = fields.String() @@ -276,16 +289,16 @@ def test_one_of_validator(): dumped = json_schema.dump(schema).data _validate_schema(dumped) assert ( - dumped['definitions']['UserSchema']['properties']['sex']['enum'] == [ - 'male', 'female', 'non_binary', 'other' - ] + dumped['definitions']['UserSchema']['properties']['sex']['enum'] == [ + 'male', 'female', 'non_binary', 'other' + ] ) assert ( - dumped['definitions']['UserSchema']['properties']['sex'][ - 'enumNames' - ] == [ - 'Male', 'Female', 'Non-binary/fluid', 'Other' - ] + dumped['definitions']['UserSchema']['properties']['sex'][ + 'enumNames' + ] == [ + 'Male', 'Female', 'Non-binary/fluid', 'Other' + ] ) @@ -298,6 +311,7 @@ def test_range_validator(): assert props['floor']['minimum'] == 1 assert props['floor']['maximum'] == 4 + def test_length_validator(): schema = UserSchema() json_schema = JSONSchema() @@ -311,9 +325,11 @@ def test_length_validator(): assert props['const']['minLength'] == 50 assert props['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): @@ -323,12 +339,16 @@ class BadSchema(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) @@ -341,8 +361,10 @@ class SchemaWithIntRangeNoValidate(Schema): 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() @@ -358,16 +380,17 @@ 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['definitions']['TestSchema']['properties']['myfield'][ - 'title' - ] == 'Brown Cowzz' + 'title' + ] == 'Brown Cowzz' -def test_unknown_typed_field_throws_valueerror(): +def test_unknown_typed_field_throws_valueerror(): class Invalid(fields.Field): def _serialize(self, value, attr, obj): return value @@ -380,8 +403,8 @@ class UserSchema(Schema): with pytest.raises(ValueError): json_schema.dump(schema).data -def test_unknown_typed_field(): +def test_unknown_typed_field(): class Colour(fields.Field): def _jsonschema_type_mapping(self): @@ -404,8 +427,8 @@ class UserSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data assert dumped['definitions']['UserSchema']['properties'][ - 'favourite_colour' - ] == {'type': 'string'} + 'favourite_colour' + ] == {'type': 'string'} def test_readonly(): @@ -417,15 +440,17 @@ class TestSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data assert dumped['definitions']['TestSchema']['properties'][ - 'readonly_fld' - ] == { - 'title': 'readonly_fld', - 'type': 'string', - 'readonly': True, - } + 'readonly_fld' + ] == { + 'title': 'readonly_fld', + 'type': 'string', + 'readonly': True, + } + def test_metadata_direct_from_field(): """Should be able to get metadata without accessing metadata kwarg.""" + class TestSchema(Schema): id = fields.Integer(required=True) metadata_field = fields.String(description='Directly on the field!') @@ -434,12 +459,13 @@ class TestSchema(Schema): json_schema = JSONSchema() dumped = json_schema.dump(schema).data assert dumped['definitions']['TestSchema']['properties'][ - 'metadata_field' - ] == { - 'title': 'metadata_field', - 'type': 'string', - 'description': 'Directly on the field!', - } + 'metadata_field' + ] == { + 'title': 'metadata_field', + 'type': 'string', + 'description': 'Directly on the field!', + } + def test_dumps_iterable_enums(): mapping = {'a': 0, 'b': 1, 'c': 2} @@ -460,12 +486,70 @@ class TestSchema(Schema): 'type': 'number' } -def test_required_excluded_when_empty(): +def test_required_excluded_when_empty(): class TestSchema(Schema): optional_value = fields.String() + schema = TestSchema() json_schema = JSONSchema() dumped = json_schema.dump(schema).data assert 'required' not in dumped['definitions']['TestSchema'] + +def test_use_datakey_for_title(): + # use datakey for marshmallow v3 + if marshmallow.__version__.split('.', 1)[0] >= '3': + class TestSchema(Schema): + normal_field = fields.String() + data_key = fields.String(data_key='dataKey') + required = fields.String(data_key='isRequired', required = True) + + schema = TestSchema() + json_schema = JSONSchema(prefer_data_key=True) + dumped = json_schema.dump(schema).data + + normal_field = dumped['definitions']['TestSchema']['properties'].get('normal_field') + assert normal_field is not None + assert normal_field.get('title') == 'normal_field' + + data_key = dumped['definitions']['TestSchema']['properties'].get('dataKey') + assert data_key is not None + assert data_key.get('title') == 'dataKey' + + required = dumped['definitions']['TestSchema']['required'] + assert 'isRequired' in required + + # use load_from, then dump_to for marshmallow < v3 + else: + class TestSchema(Schema): + normal_field = fields.String() + load_from = fields.String(load_from='loadFrom') + dump_to = fields.String(dump_to='dumpTo') + prefer_load_from = fields.String(load_from='both', dump_to='dumpTo') + required = fields.String(dump_to='isRequired', required=True) + + schema = TestSchema() + json_schema = JSONSchema(prefer_data_key=True) + dumped = json_schema.dump(schema).data + + normal_field = dumped['definitions']['TestSchema']['properties'].get('normal_field') + assert normal_field is not None + assert normal_field.get('title') == 'normal_field' + + load_from = dumped['definitions']['TestSchema']['properties'].get('loadFrom') + import json + print(json.dumps(dumped, indent=4)) + assert load_from is not None + assert load_from.get('title') == 'loadFrom' + + dump_to = dumped['definitions']['TestSchema']['properties'].get('dumpTo') + assert dump_to is not None + assert dump_to.get('title') == 'dumpTo' + + prefer_load_from = dumped['definitions']['TestSchema']['properties'].get('both') + assert prefer_load_from is not None + assert prefer_load_from.get('title') == 'both' + + required = dumped['definitions']['TestSchema']['required'] + assert 'isRequired' in required