diff --git a/openapi_core/schema/parameters/models.py b/openapi_core/schema/parameters/models.py index f33773f..090bf0a 100644 --- a/openapi_core/schema/parameters/models.py +++ b/openapi_core/schema/parameters/models.py @@ -109,7 +109,11 @@ class Parameter(object): raise InvalidParameterValue(self.name, exc) try: - unmarshalled = self.schema.unmarshal(deserialized, custom_formatters=custom_formatters) + unmarshalled = self.schema.unmarshal( + deserialized, + custom_formatters=custom_formatters, + strict=False, + ) except OpenAPISchemaError as exc: raise InvalidParameterValue(self.name, exc) diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py index 50f04ba..136fbd0 100644 --- a/openapi_core/schema/schemas/factories.py +++ b/openapi_core/schema/schemas/factories.py @@ -28,7 +28,8 @@ class SchemaFactory(object): deprecated = schema_deref.get('deprecated', False) all_of_spec = schema_deref.get('allOf', None) one_of_spec = schema_deref.get('oneOf', None) - additional_properties_spec = schema_deref.get('additionalProperties') + additional_properties_spec = schema_deref.get('additionalProperties', + True) min_items = schema_deref.get('minItems', None) max_items = schema_deref.get('maxItems', None) min_length = schema_deref.get('minLength', None) @@ -59,8 +60,8 @@ class SchemaFactory(object): if items_spec: items = self._create_items(items_spec) - additional_properties = None - if additional_properties_spec: + additional_properties = additional_properties_spec + if isinstance(additional_properties_spec, dict): additional_properties = self.create(additional_properties_spec) return Schema( diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py index f221e50..0351e1a 100644 --- a/openapi_core/schema/schemas/models.py +++ b/openapi_core/schema/schemas/models.py @@ -39,9 +39,6 @@ class Schema(object): """Represents an OpenAPI Schema.""" DEFAULT_CAST_CALLABLE_GETTER = { - SchemaType.INTEGER: int, - SchemaType.NUMBER: float, - SchemaType.BOOLEAN: forcebool, } STRING_FORMAT_CALLABLE_GETTER = { @@ -69,7 +66,7 @@ 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, min_items=None, max_items=None, + additional_properties=True, 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, @@ -149,12 +146,15 @@ class Schema(object): return set(required) - def get_cast_mapping(self, custom_formatters=None): + def get_cast_mapping(self, custom_formatters=None, strict=True): pass_defaults = lambda f: functools.partial( - f, custom_formatters=custom_formatters) + f, custom_formatters=custom_formatters, strict=strict) mapping = self.DEFAULT_CAST_CALLABLE_GETTER.copy() mapping.update({ SchemaType.STRING: pass_defaults(self._unmarshal_string), + SchemaType.BOOLEAN: pass_defaults(self._unmarshal_boolean), + SchemaType.INTEGER: pass_defaults(self._unmarshal_integer), + SchemaType.NUMBER: pass_defaults(self._unmarshal_number), SchemaType.ANY: pass_defaults(self._unmarshal_any), SchemaType.ARRAY: pass_defaults(self._unmarshal_collection), SchemaType.OBJECT: pass_defaults(self._unmarshal_object), @@ -162,14 +162,15 @@ class Schema(object): return defaultdict(lambda: lambda x: x, mapping) - def cast(self, value, custom_formatters=None): + def cast(self, value, custom_formatters=None, strict=True): """Cast value to schema type""" if value is None: if not self.nullable: raise InvalidSchemaValue("Null value for non-nullable schema", value, self.type) return self.default - cast_mapping = self.get_cast_mapping(custom_formatters=custom_formatters) + cast_mapping = self.get_cast_mapping( + custom_formatters=custom_formatters, strict=strict) if self.type is not SchemaType.STRING and value == '': return None @@ -181,12 +182,12 @@ class Schema(object): raise InvalidSchemaValue( "Failed to cast value {value} to type {type}", value, self.type) - def unmarshal(self, value, custom_formatters=None): + def unmarshal(self, value, custom_formatters=None, strict=True): """Unmarshal parameter from the value.""" if self.deprecated: warnings.warn("The schema is deprecated", DeprecationWarning) - casted = self.cast(value, custom_formatters=custom_formatters) + casted = self.cast(value, custom_formatters=custom_formatters, strict=strict) if casted is None and not self.required: return None @@ -197,7 +198,10 @@ class Schema(object): return casted - def _unmarshal_string(self, value, custom_formatters=None): + def _unmarshal_string(self, value, custom_formatters=None, strict=True): + if strict and not isinstance(value, (text_type, binary_type)): + raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) + try: schema_format = SchemaFormat(self.format) except ValueError: @@ -217,7 +221,25 @@ class Schema(object): raise InvalidCustomFormatSchemaValue( "Failed to format value {value} to format {type}: {exception}", value, self.format, exc) - def _unmarshal_any(self, value, custom_formatters=None): + def _unmarshal_integer(self, value, custom_formatters=None, strict=True): + if strict and not isinstance(value, (integer_types, )): + raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) + + return int(value) + + def _unmarshal_number(self, value, custom_formatters=None, strict=True): + if strict and not isinstance(value, (float, )): + raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) + + return float(value) + + def _unmarshal_boolean(self, value, custom_formatters=None, strict=True): + if strict and not isinstance(value, (bool, )): + raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) + + return forcebool(value) + + def _unmarshal_any(self, value, custom_formatters=None, strict=True): types_resolve_order = [ SchemaType.OBJECT, SchemaType.ARRAY, SchemaType.BOOLEAN, SchemaType.INTEGER, SchemaType.NUMBER, SchemaType.STRING, @@ -233,16 +255,21 @@ class Schema(object): raise NoValidSchema(value) - def _unmarshal_collection(self, value, custom_formatters=None): + def _unmarshal_collection(self, value, custom_formatters=None, strict=True): + if not isinstance(value, (list, tuple)): + raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) + if self.items is None: raise UndefinedItemsSchema(self.type) - f = functools.partial(self.items.unmarshal, - custom_formatters=custom_formatters) + f = functools.partial( + self.items.unmarshal, + custom_formatters=custom_formatters, strict=strict, + ) return list(map(f, value)) def _unmarshal_object(self, value, model_factory=None, - custom_formatters=None): + custom_formatters=None, strict=True): if not isinstance(value, (dict, )): raise InvalidSchemaValue("Value {value} is not of type {type}", value, self.type) @@ -271,7 +298,7 @@ class Schema(object): return model_factory.create(properties, name=self.model) def _unmarshal_properties(self, value, one_of_schema=None, - custom_formatters=None): + custom_formatters=None, strict=True): all_props = self.get_all_properties() all_props_names = self.get_all_properties_names() all_req_props_names = self.get_all_required_properties_names() @@ -285,14 +312,15 @@ class Schema(object): value_props_names = value.keys() extra_props = set(value_props_names) - set(all_props_names) - if extra_props and self.additional_properties is None: + if extra_props and self.additional_properties is False: raise UndefinedSchemaProperty(extra_props) properties = {} - for prop_name in extra_props: - prop_value = value[prop_name] - properties[prop_name] = self.additional_properties.unmarshal( - prop_value, custom_formatters=custom_formatters) + if self.additional_properties is not True: + for prop_name in extra_props: + prop_value = value[prop_name] + properties[prop_name] = self.additional_properties.unmarshal( + prop_value, custom_formatters=custom_formatters) for prop_name, prop in iteritems(all_props): try: @@ -516,13 +544,14 @@ class Schema(object): value_props_names = value.keys() extra_props = set(value_props_names) - set(all_props_names) - if extra_props and self.additional_properties is None: + if extra_props and self.additional_properties is False: raise UndefinedSchemaProperty(extra_props) - for prop_name in extra_props: - prop_value = value[prop_name] - self.additional_properties.validate( - prop_value, custom_formatters=custom_formatters) + if self.additional_properties is not True: + for prop_name in extra_props: + prop_value = value[prop_name] + self.additional_properties.validate( + prop_value, custom_formatters=custom_formatters) for prop_name, prop in iteritems(all_props): try: diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index 3f132c3..efd817d 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -59,16 +59,7 @@ paths: explode: false responses: '200': - description: An paged array of pets - headers: - x-next: - description: A link to the next page of responses - schema: - type: string - content: - application/json: - schema: - $ref: "#/components/schemas/PetsData" + $ref: "#/components/responses/PetsResponse" post: summary: Create a pet operationId: createPets @@ -295,6 +286,7 @@ components: $ref: "#/components/schemas/Utctime" name: type: string + additionalProperties: false TagList: type: array items: @@ -327,9 +319,20 @@ components: additionalProperties: type: string responses: - ErrorResponse: - description: unexpected error - content: - application/json: - schema: - $ref: "#/components/schemas/ExtendedError" + ErrorResponse: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ExtendedError" + PetsResponse: + description: An paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/PetsData" diff --git a/tests/integration/test_petstore.py b/tests/integration/test_petstore.py index e1a3c8e..b61b25d 100644 --- a/tests/integration/test_petstore.py +++ b/tests/integration/test_petstore.py @@ -17,8 +17,9 @@ from openapi_core.schema.parameters.models import Parameter from openapi_core.schema.paths.models import Path from openapi_core.schema.request_bodies.models import RequestBody from openapi_core.schema.responses.models import Response +from openapi_core.schema.schemas.enums import SchemaType from openapi_core.schema.schemas.exceptions import ( - NoValidSchema, + NoValidSchema, InvalidSchemaProperty, InvalidSchemaValue, ) from openapi_core.schema.schemas.models import Schema from openapi_core.schema.servers.exceptions import InvalidServer @@ -234,6 +235,105 @@ class TestPetstore(object): assert isinstance(response_result.data, BaseModel) assert response_result.data.data == [] + def test_get_pets_response(self, spec, response_validator): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/pets' + query_params = { + 'limit': '20', + } + + request = MockRequest( + host_url, 'GET', '/pets', + path_pattern=path_pattern, args=query_params, + ) + + parameters = request.get_parameters(spec) + body = request.get_body(spec) + + assert parameters == { + 'query': { + 'limit': 20, + 'page': 1, + 'search': '', + } + } + assert body is None + + data_json = { + 'data': [ + { + 'id': 1, + 'name': 'Cat', + } + ], + } + data = json.dumps(data_json) + response = MockResponse(data) + + response_result = response_validator.validate(request, response) + + assert response_result.errors == [] + assert isinstance(response_result.data, BaseModel) + assert len(response_result.data.data) == 1 + assert response_result.data.data[0].id == 1 + assert response_result.data.data[0].name == 'Cat' + + def test_get_pets_invalid_response(self, spec, response_validator): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/pets' + query_params = { + 'limit': '20', + } + + request = MockRequest( + host_url, 'GET', '/pets', + path_pattern=path_pattern, args=query_params, + ) + + parameters = request.get_parameters(spec) + body = request.get_body(spec) + + assert parameters == { + 'query': { + 'limit': 20, + 'page': 1, + 'search': '', + } + } + assert body is None + + data_json = { + 'data': [ + { + 'id': 1, + 'name': { + 'first_name': 'Cat', + }, + } + ], + } + data = json.dumps(data_json) + response = MockResponse(data) + + response_result = response_validator.validate(request, response) + + assert response_result.errors == [ + InvalidMediaTypeValue( + original_exception=InvalidSchemaProperty( + property_name='data', + original_exception=InvalidSchemaProperty( + property_name='name', + original_exception=InvalidSchemaValue( + msg="Value {value} is not of type {type}", + type=SchemaType.STRING, + value={'first_name': 'Cat'}, + ), + ), + ), + ), + ] + assert response_result.data is None + def test_get_pets_ids_param(self, spec, response_validator): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' @@ -419,7 +519,7 @@ class TestPetstore(object): data_json = { 'name': pet_name, 'tag': pet_tag, - 'position': '2', + 'position': 2, 'address': { 'street': pet_street, 'city': pet_city, @@ -479,7 +579,7 @@ class TestPetstore(object): data_json = { 'name': pet_name, 'tag': pet_tag, - 'position': '2', + 'position': 2, 'address': { 'street': pet_street, 'city': pet_city, @@ -535,11 +635,11 @@ class TestPetstore(object): pet_tag = 'cats' pet_street = 'Piekna' pet_city = 'Warsaw' - pet_healthy = 'false' + pet_healthy = False data_json = { 'name': pet_name, 'tag': pet_tag, - 'position': '2', + 'position': 2, 'address': { 'street': pet_street, 'city': pet_city, diff --git a/tests/integration/test_validators.py b/tests/integration/test_validators.py index bfdd4f9..c8dc2e1 100644 --- a/tests/integration/test_validators.py +++ b/tests/integration/test_validators.py @@ -155,7 +155,7 @@ class TestRequestValidator(object): data_json = { 'name': pet_name, 'tag': pet_tag, - 'position': '2', + 'position': 2, 'address': { 'street': pet_street, 'city': pet_city, diff --git a/tests/unit/schema/test_schemas.py b/tests/unit/schema/test_schemas.py index a6b415d..6bedd65 100644 --- a/tests/unit/schema/test_schemas.py +++ b/tests/unit/schema/test_schemas.py @@ -8,6 +8,7 @@ from openapi_core.extensions.models.models import Model from openapi_core.schema.schemas.enums import SchemaFormat, SchemaType from openapi_core.schema.schemas.exceptions import ( InvalidSchemaValue, MultipleOneOfSchema, NoOneOfSchema, OpenAPISchemaError, + UndefinedSchemaProperty ) from openapi_core.schema.schemas.models import Schema @@ -65,6 +66,13 @@ class TestSchemaUnmarshal(object): assert result == value + def test_string_float_invalid(self): + schema = Schema('string') + value = 1.23 + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + def test_string_none(self): schema = Schema('string') value = None @@ -134,7 +142,7 @@ class TestSchemaUnmarshal(object): value = 'x' with mock.patch.dict( - Schema.STRING_FORMAT_CAST_CALLABLE_GETTER, + Schema.STRING_FORMAT_CALLABLE_GETTER, {custom_format: mock.Mock(side_effect=ValueError())}, ), pytest.raises( InvalidSchemaValue, message='Failed to format value' @@ -143,12 +151,19 @@ class TestSchemaUnmarshal(object): def test_integer_valid(self): schema = Schema('integer') - value = '123' + value = 123 result = schema.unmarshal(value) assert result == int(value) + def test_integer_string_invalid(self): + schema = Schema('integer') + value = '123' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + def test_integer_enum_invalid(self): schema = Schema('integer', enum=[1, 2, 3]) value = '123' @@ -158,12 +173,19 @@ class TestSchemaUnmarshal(object): def test_integer_enum(self): schema = Schema('integer', enum=[1, 2, 3]) - value = '2' + value = 2 result = schema.unmarshal(value) assert result == int(value) + def test_integer_enum_string_invalid(self): + schema = Schema('integer', enum=[1, 2, 3]) + value = '2' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + def test_integer_default(self): default_value = '123' schema = Schema('integer', default=default_value) @@ -188,6 +210,65 @@ class TestSchemaUnmarshal(object): with pytest.raises(InvalidSchemaValue): schema.unmarshal(value) + def test_array_valid(self): + schema = Schema('array', items=Schema('integer')) + value = [1, 2, 3] + + result = schema.unmarshal(value) + + assert result == value + + def test_array_of_string_string_invalid(self): + schema = Schema('array', items=Schema('string')) + value = '123' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + + def test_array_of_integer_string_invalid(self): + schema = Schema('array', items=Schema('integer')) + value = '123' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + + def test_boolean_valid(self): + schema = Schema('boolean') + value = True + + result = schema.unmarshal(value) + + assert result == value + + def test_boolean_string_invalid(self): + schema = Schema('boolean') + value = 'True' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + + def test_number_valid(self): + schema = Schema('number') + value = 1.23 + + result = schema.unmarshal(value) + + assert result == value + + def test_number_string_invalid(self): + schema = Schema('number') + value = '1.23' + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + + def test_number_int_invalid(self): + schema = Schema('number') + value = 1 + + with pytest.raises(InvalidSchemaValue): + schema.unmarshal(value) + class TestSchemaValidate(object): @@ -714,6 +795,26 @@ class TestSchemaValidate(object): assert result == value + @pytest.mark.parametrize('value', [Model({'additional': 1}), ]) + def test_object_additional_propetries(self, value): + schema = Schema('object') + + schema.validate(value) + + @pytest.mark.parametrize('value', [Model({'additional': 1}), ]) + def test_object_additional_propetries_false(self, value): + schema = Schema('object', additional_properties=False) + + with pytest.raises(UndefinedSchemaProperty): + schema.validate(value) + + @pytest.mark.parametrize('value', [Model({'additional': 1}), ]) + def test_object_additional_propetries_object(self, value): + additional_properties = Schema('integer') + schema = Schema('object', additional_properties=additional_properties) + + schema.validate(value) + @pytest.mark.parametrize('value', [[], ]) def test_list_min_items_invalid_schema(self, value): schema = Schema( @@ -780,3 +881,65 @@ class TestSchemaValidate(object): with pytest.raises(Exception): schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({ + 'someint': 123, + }), + Model({ + 'somestr': u('content'), + }), + Model({ + 'somestr': u('content'), + 'someint': 123, + }), + ]) + def test_object_with_properties(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string'), + 'someint': Schema('integer'), + }, + ) + + result = schema.validate(value) + + assert result == value + + @pytest.mark.parametrize('value', [ + Model({ + 'somestr': Model(), + 'someint': 123, + }), + Model({ + 'somestr': {}, + 'someint': 123, + }), + Model({ + 'somestr': [ + 'content1', 'content2' + ], + 'someint': 123, + }), + Model({ + 'somestr': 123, + 'someint': 123, + }), + Model({ + 'somestr': 'content', + 'someint': 123, + 'not_in_scheme_prop': 123, + }), + ]) + def test_object_with_invalid_properties(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string'), + 'someint': Schema('integer'), + }, + ) + + with pytest.raises(Exception): + schema.validate(value)