From 8db5c08ed18938769fb54e6cb6ff8b07d1527a08 Mon Sep 17 00:00:00 2001 From: Correl Roush Date: Fri, 7 Sep 2018 16:40:10 -0400 Subject: [PATCH] Add support for additional validation properties Add support for the following validation properties: - multipleOf - maximum - exclusiveMaximum - minimum - exclusiveMinimum - maxLength - minLength - pattern - maxItems - minItems - uniqueItems - maxProperties - minProperties Fixes #49 --- openapi_core/schema/schemas/factories.py | 19 ++ openapi_core/schema/schemas/models.py | 138 +++++++++- tests/unit/schema/test_schemas.py | 322 +++++++++++++++++++++++ 3 files changed, 478 insertions(+), 1 deletion(-) diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py index e770c0e..50f04ba 100644 --- a/openapi_core/schema/schemas/factories.py +++ b/openapi_core/schema/schemas/factories.py @@ -29,6 +29,19 @@ class SchemaFactory(object): all_of_spec = schema_deref.get('allOf', None) one_of_spec = schema_deref.get('oneOf', None) additional_properties_spec = schema_deref.get('additionalProperties') + min_items = schema_deref.get('minItems', None) + max_items = schema_deref.get('maxItems', None) + min_length = schema_deref.get('minLength', None) + max_length = schema_deref.get('maxLength', None) + pattern = schema_deref.get('pattern', None) + unique_items = schema_deref.get('uniqueItems', None) + minimum = schema_deref.get('minimum', None) + maximum = schema_deref.get('maximum', None) + multiple_of = schema_deref.get('multipleOf', None) + exclusive_minimum = schema_deref.get('exclusiveMinimum', False) + exclusive_maximum = schema_deref.get('exclusiveMaximum', False) + min_properties = schema_deref.get('minProperties', None) + max_properties = schema_deref.get('maxProperties', None) properties = None if properties_spec: @@ -56,6 +69,12 @@ class SchemaFactory(object): default=default, nullable=nullable, enum=enum, deprecated=deprecated, all_of=all_of, one_of=one_of, additional_properties=additional_properties, + min_items=min_items, max_items=max_items, min_length=min_length, + max_length=max_length, pattern=pattern, unique_items=unique_items, + minimum=minimum, maximum=maximum, multiple_of=multiple_of, + exclusive_maximum=exclusive_maximum, + exclusive_minimum=exclusive_minimum, + min_properties=min_properties, max_properties=max_properties, ) @property diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py index 2aabf45..c0014c5 100644 --- a/openapi_core/schema/schemas/models.py +++ b/openapi_core/schema/schemas/models.py @@ -2,6 +2,7 @@ import logging from collections import defaultdict from datetime import date, datetime +import re import warnings from six import iteritems, integer_types, binary_type, text_type @@ -61,7 +62,11 @@ class Schema(object): self, schema_type=None, model=None, properties=None, items=None, schema_format=None, required=None, default=None, nullable=False, enum=None, deprecated=False, all_of=None, one_of=None, - additional_properties=None): + additional_properties=None, min_items=None, max_items=None, + min_length=None, max_length=None, pattern=None, unique_items=False, + minimum=None, maximum=None, multiple_of=None, + exclusive_minimum=False, exclusive_maximum=False, + min_properties=None, max_properties=None): self.type = SchemaType(schema_type) self.model = model self.properties = properties and dict(properties) or {} @@ -75,6 +80,22 @@ class Schema(object): self.all_of = all_of and list(all_of) or [] self.one_of = one_of and list(one_of) or [] self.additional_properties = additional_properties + self.min_items = int(min_items) if min_items is not None else None + self.max_items = int(max_items) if max_items is not None else None + self.min_length = int(min_length) if min_length is not None else None + self.max_length = int(max_length) if max_length is not None else None + self.pattern = pattern and re.compile(pattern) or None + self.unique_items = unique_items + self.minimum = int(minimum) if minimum is not None else None + self.maximum = int(maximum) if maximum is not None else None + self.multiple_of = int(multiple_of)\ + if multiple_of is not None else None + self.exclusive_minimum = exclusive_minimum + self.exclusive_maximum = exclusive_maximum + self.min_properties = int(min_properties)\ + if min_properties is not None else None + self.max_properties = int(max_properties)\ + if max_properties is not None else None self._all_required_properties_cache = None self._all_optional_properties_cache = None @@ -288,6 +309,8 @@ class Schema(object): SchemaType.ARRAY: self._validate_collection, SchemaType.STRING: self._validate_string, SchemaType.OBJECT: self._validate_object, + SchemaType.INTEGER: self._validate_number, + SchemaType.NUMBER: self._validate_number, } return defaultdict(lambda: lambda x: x, mapping) @@ -318,8 +341,66 @@ class Schema(object): if self.items is None: raise OpenAPISchemaError("Schema for collection not defined") + if self.min_items is not None: + if self.min_items < 0: + raise OpenAPISchemaError( + "Schema for collection invalid:" + " minItems must be non-negative" + ) + if len(value) < self.min_items: + raise InvalidSchemaValue( + "Value must contain at least {0} item(s)," + " {1} found".format( + self.min_items, len(value)) + ) + if self.max_items is not None: + if self.max_items < 0: + raise OpenAPISchemaError( + "Schema for collection invalid:" + " maxItems must be non-negative" + ) + if len(value) > self.max_items: + raise InvalidSchemaValue( + "Value must contain at most {0} item(s)," + " {1} found".format( + self.max_items, len(value)) + ) + if self.unique_items and len(set(value)) != len(value): + raise InvalidSchemaValue("Value may not contain duplicate items") + return list(map(self.items.validate, value)) + def _validate_number(self, value): + if self.minimum is not None: + if self.exclusive_minimum and value <= self.minimum: + raise InvalidSchemaValue( + "Value {0} is not less than or equal to {1}".format( + value, self.minimum) + ) + elif value < self.minimum: + raise InvalidSchemaValue( + "Value {0} is not less than {1}".format( + value, self.minimum) + ) + + if self.maximum is not None: + if self.exclusive_maximum and value >= self.maximum: + raise InvalidSchemaValue( + "Value {0} is not greater than or equal to {1}".format( + value, self.maximum) + ) + elif value > self.maximum: + raise InvalidSchemaValue( + "Value {0} is not greater than {1}".format( + value, self.maximum) + ) + + if self.multiple_of is not None and value % self.multiple_of: + raise InvalidSchemaValue( + "Value {0} is not a multiple of {1}".format( + value, self.multiple_of) + ) + def _validate_string(self, value): try: schema_format = SchemaFormat(self.format) @@ -338,6 +419,34 @@ class Schema(object): value, self.format) ) + if self.min_length is not None: + if self.min_length < 0: + raise OpenAPISchemaError( + "Schema for string invalid:" + " minLength must be non-negative" + ) + if len(value) < self.min_length: + raise InvalidSchemaValue( + "Value is shorter than the minimum length of {0}".format( + self.min_length) + ) + if self.max_length is not None: + if self.max_length < 0: + raise OpenAPISchemaError( + "Schema for string invalid:" + " maxLength must be non-negative" + ) + if len(value) > self.max_length: + raise InvalidSchemaValue( + "Value is longer than the maximum length of {0}".format( + self.max_length) + ) + if self.pattern is not None and not self.pattern.search(value): + raise InvalidSchemaValue( + "Value {0} does not match the pattern {1}".format( + value, self.pattern.pattern) + ) + return True def _validate_object(self, value): @@ -364,6 +473,33 @@ class Schema(object): else: self._validate_properties(properties) + if self.min_properties is not None: + if self.min_properties < 0: + raise OpenAPISchemaError( + "Schema for object invalid:" + " minProperties must be non-negative" + ) + + if len(properties) < self.min_properties: + raise InvalidSchemaValue( + "Value must contain at least {0} properties," + " {1} found".format( + self.min_properties, len(properties)) + ) + + if self.max_properties is not None: + if self.max_properties < 0: + raise OpenAPISchemaError( + "Schema for object invalid:" + " maxProperties must be non-negative" + ) + if len(properties) > self.max_properties: + raise InvalidSchemaValue( + "Value must contain at most {0} properties," + " {1} found".format( + self.max_properties, len(properties)) + ) + return True def _validate_properties(self, value, one_of_schema=None): diff --git a/tests/unit/schema/test_schemas.py b/tests/unit/schema/test_schemas.py index 2709e63..eb489e1 100644 --- a/tests/unit/schema/test_schemas.py +++ b/tests/unit/schema/test_schemas.py @@ -246,6 +246,51 @@ class TestSchemaValidate(object): with pytest.raises(InvalidSchemaValue): schema.validate(value) + @pytest.mark.parametrize('value', [0, 1, 2]) + def test_integer_minimum_invalid(self, value): + schema = Schema('integer', minimum=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [4, 5, 6]) + def test_integer_minimum(self, value): + schema = Schema('integer', minimum=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [4, 5, 6]) + def test_integer_maximum_invalid(self, value): + schema = Schema('integer', maximum=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [0, 1, 2]) + def test_integer_maximum(self, value): + schema = Schema('integer', maximum=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [1, 2, 4]) + def test_integer_multiple_of_invalid(self, value): + schema = Schema('integer', multiple_of=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [3, 6, 18]) + def test_integer_multiple_of(self, value): + schema = Schema('integer', multiple_of=3) + + result = schema.validate(value) + + assert result == value + @pytest.mark.parametrize('value', [1, 3.14]) def test_number(self, value): schema = Schema('number') @@ -261,6 +306,81 @@ class TestSchemaValidate(object): with pytest.raises(InvalidSchemaValue): schema.validate(value) + @pytest.mark.parametrize('value', [0, 1, 2]) + def test_number_minimum_invalid(self, value): + schema = Schema('number', minimum=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [3, 4, 5]) + def test_number_minimum(self, value): + schema = Schema('number', minimum=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [1, 2, 3]) + def test_number_exclusive_minimum_invalid(self, value): + schema = Schema('number', minimum=3, exclusive_minimum=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [4, 5, 6]) + def test_number_exclusive_minimum(self, value): + schema = Schema('number', minimum=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [4, 5, 6]) + def test_number_maximum_invalid(self, value): + schema = Schema('number', maximum=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [1, 2, 3]) + def test_number_maximum(self, value): + schema = Schema('number', maximum=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [3, 4, 5]) + def test_number_exclusive_maximum_invalid(self, value): + schema = Schema('number', maximum=3, exclusive_maximum=True) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [0, 1, 2]) + def test_number_exclusive_maximum(self, value): + schema = Schema('number', maximum=3, exclusive_maximum=True) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [1, 2, 4]) + def test_number_multiple_of_invalid(self, value): + schema = Schema('number', multiple_of=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [3, 6, 18]) + def test_number_multiple_of(self, value): + schema = Schema('number', multiple_of=3) + + result = schema.validate(value) + + assert result == value + @pytest.mark.parametrize('value', [u('true'), ]) def test_string(self, value): schema = Schema('string') @@ -348,6 +468,65 @@ class TestSchemaValidate(object): with pytest.raises(OpenAPISchemaError): schema.validate(value) + @pytest.mark.parametrize('value', [u(""), ]) + def test_string_min_length_invalid_schema(self, value): + schema = Schema('string', min_length=-1) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [u(""), u("a"), u("ab")]) + def test_string_min_length_invalid(self, value): + schema = Schema('string', min_length=3) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [u("abc"), u("abcd")]) + def test_string_min_length(self, value): + schema = Schema('string', min_length=3) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [u(""), ]) + def test_string_max_length_invalid_schema(self, value): + schema = Schema('string', max_length=-1) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [u("ab"), u("abc")]) + def test_string_max_length_invalid(self, value): + schema = Schema('string', max_length=1) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [u(""), u("a")]) + def test_string_max_length(self, value): + schema = Schema('string', max_length=1) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [u("foo"), u("bar")]) + def test_string_pattern_invalid(self, value): + schema = Schema('string', pattern='baz') + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [u("bar"), u("foobar")]) + def test_string_pattern(self, value): + schema = Schema('string', pattern='bar') + + result = schema.validate(value) + + assert result == value + @pytest.mark.parametrize('value', ['true', False, 1, 3.14, [1, 3]]) def test_object_not_an_object(self, value): schema = Schema('object') @@ -401,3 +580,146 @@ class TestSchemaValidate(object): result = schema.validate(value) assert result == value + + @pytest.mark.parametrize('value', [Model(), ]) + def test_object_min_properties_invalid_schema(self, value): + schema = Schema('object', min_properties=-1) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({'a': 1}), + Model({'a': 1, 'b': 2}), + Model({'a': 1, 'b': 2, 'c': 3})]) + def test_object_min_properties_invalid(self, value): + schema = Schema( + 'object', + properties={k: Schema('number') + for k in ['a', 'b', 'c']}, + min_properties=4, + ) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({'a': 1}), + Model({'a': 1, 'b': 2}), + Model({'a': 1, 'b': 2, 'c': 3})]) + def test_object_min_properties(self, value): + schema = Schema( + 'object', + properties={k: Schema('number') + for k in ['a', 'b', 'c']}, + min_properties=1, + ) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [Model(), ]) + def test_object_max_properties_invalid_schema(self, value): + schema = Schema('object', max_properties=-1) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({'a': 1}), + Model({'a': 1, 'b': 2}), + Model({'a': 1, 'b': 2, 'c': 3})]) + def test_object_max_properties_invalid(self, value): + schema = Schema( + 'object', + properties={k: Schema('number') + for k in ['a', 'b', 'c']}, + max_properties=0, + ) + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({'a': 1}), + Model({'a': 1, 'b': 2}), + Model({'a': 1, 'b': 2, 'c': 3})]) + def test_object_max_properties(self, value): + schema = Schema( + 'object', + properties={k: Schema('number') + for k in ['a', 'b', 'c']}, + max_properties=3, + ) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [[], ]) + def test_list_min_items_invalid_schema(self, value): + schema = Schema( + 'array', + items=Schema('number'), + min_items=-1, + ) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [[], [1], [1, 2]]) + def test_list_min_items_invalid(self, value): + schema = Schema( + 'array', + items=Schema('number'), + min_items=3, + ) + + with pytest.raises(Exception): + schema.validate(value) + + @pytest.mark.parametrize('value', [[], [1], [1, 2]]) + def test_list_min_items(self, value): + schema = Schema( + 'array', + items=Schema('number'), + min_items=0, + ) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [[], ]) + def test_list_max_items_invalid_schema(self, value): + schema = Schema( + 'array', + items=Schema('number'), + max_items=-1, + ) + + with pytest.raises(OpenAPISchemaError): + schema.validate(value) + + @pytest.mark.parametrize('value', [[1, 2], [2, 3, 4]]) + def test_list_max_items_invalid(self, value): + schema = Schema( + 'array', + items=Schema('number'), + max_items=1, + ) + + with pytest.raises(Exception): + schema.validate(value) + + @pytest.mark.parametrize('value', [[1, 2, 1], [2, 2]]) + def test_list_unique_items_invalid(self, value): + schema = Schema( + 'array', + items=Schema('number'), + unique_items=True, + ) + + with pytest.raises(Exception): + schema.validate(value)