diff --git a/.gitignore b/.gitignore index 89a475dc..ac25106a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ +reports/ # Translations *.mo diff --git a/openapi_core/schema/media_types/models.py b/openapi_core/schema/media_types/models.py index 5104095a..795ab4c1 100644 --- a/openapi_core/schema/media_types/models.py +++ b/openapi_core/schema/media_types/models.py @@ -32,7 +32,7 @@ def deserialize(self, value): deserializer = self.get_dererializer() return deserializer(value) - def unmarshal(self, value, custom_formatters=None): + def unmarshal(self, value, custom_formatters=None, read=False, write=False): if not self.schema: return value @@ -47,6 +47,6 @@ def unmarshal(self, value, custom_formatters=None): raise InvalidMediaTypeValue(exc) try: - return self.schema.validate(unmarshalled, custom_formatters=custom_formatters) + return self.schema.validate(unmarshalled, custom_formatters=custom_formatters, read=read, write=write) except OpenAPISchemaError as exc: raise InvalidMediaTypeValue(exc) diff --git a/openapi_core/schema/parameters/models.py b/openapi_core/schema/parameters/models.py index 090bf0a4..b9ed5684 100644 --- a/openapi_core/schema/parameters/models.py +++ b/openapi_core/schema/parameters/models.py @@ -89,7 +89,7 @@ def get_value(self, request): return location[self.name] - def unmarshal(self, value, custom_formatters=None): + def unmarshal(self, value, custom_formatters=None, **kwargs): if self.deprecated: warnings.warn( "{0} parameter is deprecated".format(self.name), diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py index 136fbd09..92f1a12a 100644 --- a/openapi_core/schema/schemas/factories.py +++ b/openapi_core/schema/schemas/factories.py @@ -43,6 +43,8 @@ def create(self, schema_spec): exclusive_maximum = schema_deref.get('exclusiveMaximum', False) min_properties = schema_deref.get('minProperties', None) max_properties = schema_deref.get('maxProperties', None) + read_only = schema_deref.get('readOnly', False) + write_only = schema_deref.get('writeOnly', False) properties = None if properties_spec: @@ -76,6 +78,7 @@ def create(self, schema_spec): exclusive_maximum=exclusive_maximum, exclusive_minimum=exclusive_minimum, min_properties=min_properties, max_properties=max_properties, + read_only=read_only, write_only=write_only ) @property diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py index b9784cf2..b7a1864b 100644 --- a/openapi_core/schema/schemas/models.py +++ b/openapi_core/schema/schemas/models.py @@ -9,7 +9,7 @@ import re import warnings -from six import iteritems, integer_types, binary_type, text_type +from six import iteritems, integer_types, binary_type, text_type, string_types from openapi_core.extensions.models.factories import ModelFactory from openapi_core.schema.schemas.enums import SchemaFormat, SchemaType @@ -42,14 +42,14 @@ class Schema(object): } STRING_FORMAT_CALLABLE_GETTER = { - SchemaFormat.NONE: Format(text_type, TypeValidator(text_type)), - SchemaFormat.PASSWORD: Format(text_type, TypeValidator(text_type)), + SchemaFormat.NONE: Format(text_type, TypeValidator(string_types)), + SchemaFormat.PASSWORD: Format(text_type, TypeValidator(string_types)), SchemaFormat.DATE: Format( format_date, TypeValidator(date, exclude=datetime)), SchemaFormat.DATETIME: Format(format_datetime, TypeValidator(datetime)), SchemaFormat.BINARY: Format(binary_type, TypeValidator(binary_type)), SchemaFormat.UUID: Format(format_uuid, TypeValidator(UUID)), - SchemaFormat.BYTE: Format(format_byte, TypeValidator(text_type)), + SchemaFormat.BYTE: Format(format_byte, TypeValidator(string_types)), } NUMBER_FORMAT_CALLABLE_GETTER = { @@ -79,7 +79,8 @@ def __init__( 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): + min_properties=None, max_properties=None, read_only=False, + write_only=False): self.type = SchemaType(schema_type) self.model = model self.properties = properties and dict(properties) or {} @@ -109,6 +110,8 @@ def __init__( if min_properties is not None else None self.max_properties = int(max_properties)\ if max_properties is not None else None + self.read_only = read_only + self.write_only = write_only self._all_required_properties_cache = None self._all_optional_properties_cache = None @@ -391,7 +394,7 @@ def default(x, **kw): return defaultdict(lambda: default, mapping) - def validate(self, value, custom_formatters=None): + def validate(self, value, custom_formatters=None, read=False, write=False): if value is None: if not self.nullable: raise InvalidSchemaValue("Null value for non-nullable schema of type {type}", value, self.type) @@ -407,11 +410,11 @@ def validate(self, value, custom_formatters=None): # structure validation validator_mapping = self.get_validator_mapping() validator_callable = validator_mapping[self.type] - validator_callable(value, custom_formatters=custom_formatters) + validator_callable(value, custom_formatters=custom_formatters, read=read, write=write) return value - def _validate_collection(self, value, custom_formatters=None): + def _validate_collection(self, value, custom_formatters=None, **kwargs): if self.items is None: raise UndefinedItemsSchema(self.type) @@ -439,10 +442,10 @@ def _validate_collection(self, value, custom_formatters=None): raise OpenAPISchemaError("Value may not contain duplicate items") f = functools.partial(self.items.validate, - custom_formatters=custom_formatters) + custom_formatters=custom_formatters, **kwargs) return list(map(f, value)) - def _validate_number(self, value, custom_formatters=None): + def _validate_number(self, value, **kwargs): if self.minimum is not None: if self.exclusive_minimum and value <= self.minimum: raise InvalidSchemaValue( @@ -464,7 +467,7 @@ def _validate_number(self, value, custom_formatters=None): "Value {value} is not a multiple of {type}", value, self.multiple_of) - def _validate_string(self, value, custom_formatters=None): + def _validate_string(self, value, custom_formatters=None, **kwargs): try: schema_format = SchemaFormat(self.format) except ValueError: @@ -513,7 +516,7 @@ def _validate_string(self, value, custom_formatters=None): return True - def _validate_object(self, value, custom_formatters=None): + def _validate_object(self, value, custom_formatters=None, **kwargs): properties = value.__dict__ if self.one_of: @@ -522,7 +525,7 @@ def _validate_object(self, value, custom_formatters=None): try: self._validate_properties( properties, one_of_schema, - custom_formatters=custom_formatters) + custom_formatters=custom_formatters, **kwargs) except OpenAPISchemaError: pass else: @@ -535,7 +538,7 @@ def _validate_object(self, value, custom_formatters=None): else: self._validate_properties(properties, - custom_formatters=custom_formatters) + custom_formatters=custom_formatters, **kwargs) if self.min_properties is not None: if self.min_properties < 0: @@ -565,7 +568,7 @@ def _validate_object(self, value, custom_formatters=None): return True def _validate_properties(self, value, one_of_schema=None, - custom_formatters=None): + custom_formatters=None, read=False, write=False): all_props = self.get_all_properties() all_props_names = self.get_all_properties_names() all_req_props_names = self.get_all_required_properties_names() @@ -588,19 +591,30 @@ def _validate_properties(self, value, one_of_schema=None, for prop_name in extra_props: prop_value = value[prop_name] self.additional_properties.validate( - prop_value, custom_formatters=custom_formatters) + prop_value, custom_formatters=custom_formatters, + read=read, write=write) for prop_name, prop in iteritems(all_props): + should_skip = (write and prop.read_only) or (read and prop.write_only) try: prop_value = value[prop_name] + if read and prop.write_only: + message = "WriteOnly property {prop} defined on read.".format(prop=prop_name) + raise UndefinedSchemaProperty(message) + + if write and prop.read_only: + message = "ReadOnly property {prop} defined on write.".format(prop=prop_name) + raise UndefinedSchemaProperty(message) + except KeyError: - if prop_name in all_req_props_names: + if prop_name in all_req_props_names and not should_skip: raise MissingSchemaProperty(prop_name) - if not prop.nullable and not prop.default: + if (not prop.nullable and not prop.default) or should_skip: continue prop_value = prop.default try: - prop.validate(prop_value, custom_formatters=custom_formatters) + prop.validate(prop_value, custom_formatters=custom_formatters, + read=read, write=write) except OpenAPISchemaError as exc: raise InvalidSchemaProperty(prop_name, original_exception=exc) diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index 08593f63..b323ddf5 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -53,7 +53,7 @@ def _get_parameters(self, request, operation): continue try: - value = param.unmarshal(raw_value, self.custom_formatters) + value = param.unmarshal(raw_value, self.custom_formatters, read=True) except OpenAPIMappingError as exc: errors.append(exc) else: @@ -79,7 +79,7 @@ def _get_body(self, request, operation): errors.append(exc) else: try: - body = media_type.unmarshal(raw_body, self.custom_formatters) + body = media_type.unmarshal(raw_body, self.custom_formatters, write=True) except OpenAPIMappingError as exc: errors.append(exc) diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index 4f9696bf..a221438b 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -61,7 +61,7 @@ def _get_data(self, response, operation_response): errors.append(exc) else: try: - data = media_type.unmarshal(raw_data, self.custom_formatters) + data = media_type.unmarshal(raw_data, self.custom_formatters, read=True) except OpenAPIMappingError as exc: errors.append(exc) diff --git a/tests/integration/data/v3.0/get_and_post.yaml b/tests/integration/data/v3.0/get_and_post.yaml new file mode 100644 index 00000000..cba2e2ef --- /dev/null +++ b/tests/integration/data/v3.0/get_and_post.yaml @@ -0,0 +1,52 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Test Post and Get + license: + name: MIT +paths: + /object: + post: + summary: Post an Object + operationId: postObject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectDesc' + responses: + '201': + description: Null response + /object/{objectId}: + get: + summary: Get an Object + operationId: getObject + parameters: + - name: objectId + in: path + required: true + description: The id of the object + schema: + type: string + responses: + '200': + description: Object description + content: + application/json: + schema: + $ref: "#/components/schemas/ObjectDesc" +components: + schemas: + ObjectDesc: + type: object + additionalProperties: False + properties: + object_id: + type: string + readOnly: true + message: + type: string + password: + type: string + writeOnly: true diff --git a/tests/integration/test_get_and_post.py b/tests/integration/test_get_and_post.py new file mode 100644 index 00000000..28d12576 --- /dev/null +++ b/tests/integration/test_get_and_post.py @@ -0,0 +1,75 @@ +import pytest +import json + +from openapi_core.shortcuts import create_spec +from openapi_core.validation.request.validators import RequestValidator +from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.wrappers.mock import MockRequest, MockResponse + + +class TestGetAndPost(object): + + get_object = [{ + "object_id": "random_id", + "message": "test message" + }] + + post_object = [{ + "message": "second message", + "password": "fakepassword" + }] + spec_paths = [ + "data/v3.0/get_and_post.yaml", + ] + + @pytest.mark.parametrize("response", post_object) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_post_object_success(self, factory, response, spec_path): + spec_dict = factory.spec_from_file(spec_path) + spec = create_spec(spec_dict) + validator = RequestValidator(spec) + request = MockRequest("http://www.example.com", "post", + "/object", data=json.dumps(response)) + + result = validator.validate(request) + assert not result.errors + + @pytest.mark.parametrize("response", get_object) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_post_object_failure(self, factory, response, spec_path): + spec_dict = factory.spec_from_file(spec_path) + spec = create_spec(spec_dict) + validator = RequestValidator(spec) + request = MockRequest("http://www.example.com", "post", + "/object", data=json.dumps(response)) + + result = validator.validate(request) + assert result.errors + + @pytest.mark.parametrize("response", get_object) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_get_object_success(self, factory, response, spec_path): + spec_dict = factory.spec_from_file(spec_path) + spec = create_spec(spec_dict) + request = MockRequest("http://www.example.com", "get", + "/object/{objectId}") + validator = ResponseValidator(spec) + response = MockResponse(data=json.dumps(response)) + + result = validator.validate(request, response) + print(result.errors) + assert not result.errors + + @pytest.mark.parametrize("response", post_object) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_get_object_failure(self, factory, response, spec_path): + spec_dict = factory.spec_from_file(spec_path) + spec = create_spec(spec_dict) + request = MockRequest("http://www.example.com", "get", + "/object/{objectId}") + + validator = ResponseValidator(spec) + response = MockResponse(data=json.dumps(response)) + + result = validator.validate(request, response) + assert result.errors diff --git a/tests/unit/schema/test_schemas.py b/tests/unit/schema/test_schemas.py index 8efd96be..2da1a304 100644 --- a/tests/unit/schema/test_schemas.py +++ b/tests/unit/schema/test_schemas.py @@ -1,5 +1,6 @@ import datetime import uuid +import six import mock import pytest @@ -8,7 +9,7 @@ from openapi_core.schema.schemas.enums import SchemaFormat, SchemaType from openapi_core.schema.schemas.exceptions import ( InvalidSchemaValue, MultipleOneOfSchema, NoOneOfSchema, OpenAPISchemaError, - UndefinedSchemaProperty + UndefinedSchemaProperty, MissingSchemaProperty ) from openapi_core.schema.schemas.models import Schema @@ -520,7 +521,16 @@ def test_string(self, value): assert result == value @pytest.mark.parametrize('value', [b('test'), False, 1, 3.14, [1, 3]]) - def test_string_invalid(self, value): + @pytest.mark.skip(six.PY2) + def test_string_invalid_py3(self, value): + schema = Schema('string') + + with pytest.raises(InvalidSchemaValue): + schema.validate(value) + + @pytest.mark.parametrize('value', [False, 1, 3.14, [1, 3]]) + @pytest.mark.skip(six.PY3) + def test_string_invalid_py2(self, value): schema = Schema('string') with pytest.raises(InvalidSchemaValue): @@ -610,6 +620,7 @@ def test_string_format_binary(self, value): @pytest.mark.parametrize('value', [ b('tsssst'), b('dGVzdA=='), ]) + @pytest.mark.skip(six.PY2) def test_string_format_byte_invalid(self, value): schema = Schema('string', schema_format='byte') @@ -1008,3 +1019,129 @@ def test_object_with_invalid_properties(self, value): with pytest.raises(Exception): schema.validate(value) + + @pytest.mark.parametrize('value', [ + Model({ + 'someint': 123, + }), + ]) + def test_object_write_only_pass(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + ) + + result = schema.validate(value, write=True) + + assert result == value + + @pytest.mark.parametrize('value', [ + Model({ + 'somestr': "asdf", + }), + ]) + def test_object_write_only_failed(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + ) + + with pytest.raises(UndefinedSchemaProperty) as e: + schema.validate(value, write=True) + + assert 'ReadOnly property somestr defined on write.' \ + in six.text_type(e.value) + + @pytest.mark.parametrize('value', [ + Model({ + 'somestr': "asdf" + }), + ]) + def test_object_read_only_pass(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + ) + + result = schema.validate(value, read=True) + + assert result == value + + @pytest.mark.parametrize('value', [ + Model({ + 'somestr': "asdf", + 'someint': 123 + }), + ]) + def test_object_read_only_failed(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + ) + + with pytest.raises(UndefinedSchemaProperty) as e: + schema.validate(value, read=True) + assert 'WriteOnly property someint defined on read.' \ + in six.text_type(e.value) + + @pytest.mark.parametrize('value', [ + Model({ + }), + ]) + def test_object_read_only_required_failed(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + required=['somestr', 'someint'] + ) + + with pytest.raises(MissingSchemaProperty) as e: + schema.validate(value, read=True) + assert 'Missing schema property: somestr' in six.text_type(e.value) + assert 'Missing schema property: someint' not in six.text_type(e.value) + + @pytest.mark.parametrize('value', [ + Model({ + }), + ]) + def test_object_write_only_required_failed(self, value): + schema = Schema( + 'object', + properties={ + 'somestr': Schema('string', + read_only=True), + 'someint': Schema('integer', + write_only=True), + }, + required=['somestr', 'someint'] + ) + + with pytest.raises(MissingSchemaProperty) as e: + schema.validate(value, write=True) + assert 'Missing schema property: someint' in six.text_type(e.value) + assert 'Missing schema property: somestr' not in six.text_type(e.value)