diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py index f92d598..55b48fe 100644 --- a/openapi_core/schema/schemas/factories.py +++ b/openapi_core/schema/schemas/factories.py @@ -46,6 +46,8 @@ class SchemaFactory(object): 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) extensions = self.extensions_generator.generate(schema_deref) @@ -81,7 +83,7 @@ class SchemaFactory(object): exclusive_maximum=exclusive_maximum, exclusive_minimum=exclusive_minimum, min_properties=min_properties, max_properties=max_properties, - extensions=extensions, + read_only=read_only, write_only=write_only, extensions=extensions, _source=schema_deref, ) diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py index 37fe06f..a4109c4 100644 --- a/openapi_core/schema/schemas/models.py +++ b/openapi_core/schema/schemas/models.py @@ -26,7 +26,8 @@ class Schema(object): 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, extensions=None, + min_properties=None, max_properties=None, + read_only=False, write_only=False, extensions=None, _source=None): self.type = SchemaType(schema_type) self.properties = properties and dict(properties) or {} @@ -56,6 +57,8 @@ class Schema(object): 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.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema_validator/_validators.py b/openapi_core/schema_validator/_validators.py index fc5a4ba..fdcdeae 100644 --- a/openapi_core/schema_validator/_validators.py +++ b/openapi_core/schema_validator/_validators.py @@ -35,6 +35,19 @@ def nullable(validator, is_nullable, instance, schema): yield ValidationError("None for not nullable") +def required(validator, required, instance, schema): + if not validator.is_type(instance, "object"): + return + for property in required: + if property not in instance: + prop_schema = schema['properties'][property] + read_only = prop_schema.get('readOnly', False) + write_only = prop_schema.get('writeOnly', False) + if validator.write and read_only or validator.read and write_only: + continue + yield ValidationError("%r is a required property" % property) + + def additionalProperties(validator, aP, instance, schema): if not validator.is_type(instance, "object"): return @@ -54,5 +67,21 @@ def additionalProperties(validator, aP, instance, schema): yield ValidationError(error % extras_msg(extras)) +def readOnly(validator, ro, instance, schema): + if not validator.write or not ro: + return + + yield ValidationError( + "Tried to write read-only proparty with %s" % (instance)) + + +def writeOnly(validator, wo, instance, schema): + if not validator.read or not wo: + return + + yield ValidationError( + "Tried to read write-only proparty with %s" % (instance)) + + def not_implemented(validator, value, instance, schema): pass diff --git a/openapi_core/schema_validator/validators.py b/openapi_core/schema_validator/validators.py index 14f0402..198d42a 100644 --- a/openapi_core/schema_validator/validators.py +++ b/openapi_core/schema_validator/validators.py @@ -21,7 +21,6 @@ BaseOAS30Validator = create( u"uniqueItems": _validators.uniqueItems, u"maxProperties": _validators.maxProperties, u"minProperties": _validators.minProperties, - u"required": _validators.required, u"enum": _validators.enum, # adjusted to OAS u"type": oas_validators.type, @@ -31,6 +30,7 @@ BaseOAS30Validator = create( u"not": _validators.not_, u"items": oas_validators.items, u"properties": _validators.properties, + u"required": oas_validators.required, u"additionalProperties": oas_validators.additionalProperties, # TODO: adjust description u"format": oas_validators.format, @@ -39,8 +39,8 @@ BaseOAS30Validator = create( # fixed OAS fields u"nullable": oas_validators.nullable, u"discriminator": oas_validators.not_implemented, - u"readOnly": oas_validators.not_implemented, - u"writeOnly": oas_validators.not_implemented, + u"readOnly": oas_validators.readOnly, + u"writeOnly": oas_validators.writeOnly, u"xml": oas_validators.not_implemented, u"externalDocs": oas_validators.not_implemented, u"example": oas_validators.not_implemented, @@ -54,6 +54,11 @@ BaseOAS30Validator = create( class OAS30Validator(BaseOAS30Validator): + def __init__(self, *args, **kwargs): + self.read = kwargs.pop('read', None) + self.write = kwargs.pop('write', None) + super(OAS30Validator, self).__init__(*args, **kwargs) + def iter_errors(self, instance, _schema=None): if _schema is None: _schema = self.schema diff --git a/openapi_core/unmarshalling/schemas/enums.py b/openapi_core/unmarshalling/schemas/enums.py new file mode 100644 index 0000000..ffe4ed5 --- /dev/null +++ b/openapi_core/unmarshalling/schemas/enums.py @@ -0,0 +1,7 @@ +"""OpenAPI core unmarshalling schemas enums module""" +from enum import Enum + + +class UnmarshalContext(Enum): + REQUEST = 'request' + RESPONSE = 'response' diff --git a/openapi_core/unmarshalling/schemas/factories.py b/openapi_core/unmarshalling/schemas/factories.py index 2222420..3c699a0 100644 --- a/openapi_core/unmarshalling/schemas/factories.py +++ b/openapi_core/unmarshalling/schemas/factories.py @@ -5,6 +5,7 @@ from openapi_core.schema.schemas.enums import SchemaType, SchemaFormat from openapi_core.schema.schemas.models import Schema from openapi_core.schema_validator import OAS30Validator from openapi_core.schema_validator import oas30_format_checker +from openapi_core.unmarshalling.schemas.enums import UnmarshalContext from openapi_core.unmarshalling.schemas.exceptions import ( FormatterNotFoundError, ) @@ -29,11 +30,17 @@ class SchemaUnmarshallersFactory(object): SchemaType.ANY: AnyUnmarshaller, } - def __init__(self, resolver=None, custom_formatters=None): + CONTEXT_VALIDATION = { + UnmarshalContext.REQUEST: 'write', + UnmarshalContext.RESPONSE: 'read', + } + + def __init__(self, resolver=None, custom_formatters=None, context=None): self.resolver = resolver if custom_formatters is None: custom_formatters = {} self.custom_formatters = custom_formatters + self.context = context def create(self, schema, type_override=None): """Create unmarshaller from the schema.""" @@ -50,7 +57,9 @@ class SchemaUnmarshallersFactory(object): elif schema_type in self.COMPLEX_UNMARSHALLERS: klass = self.COMPLEX_UNMARSHALLERS[schema_type] kwargs = dict( - schema=schema, unmarshallers_factory=self) + schema=schema, unmarshallers_factory=self, + context=self.context, + ) formatter = self.get_formatter(klass.FORMATTERS, schema.format) @@ -70,10 +79,17 @@ class SchemaUnmarshallersFactory(object): return default_formatters.get(schema_format) def get_validator(self, schema): - format_checker = deepcopy(oas30_format_checker) + format_checker = self._get_format_checker() + kwargs = { + 'resolver': self.resolver, + 'format_checker': format_checker, + } + if self.context is not None: + kwargs[self.CONTEXT_VALIDATION[self.context]] = True + return OAS30Validator(schema.__dict__, **kwargs) + + def _get_format_checker(self): + fc = deepcopy(oas30_format_checker) for name, formatter in self.custom_formatters.items(): - format_checker.checks(name)(formatter.validate) - return OAS30Validator( - schema.__dict__, - resolver=self.resolver, format_checker=format_checker, - ) + fc.checks(name)(formatter.validate) + return fc diff --git a/openapi_core/unmarshalling/schemas/unmarshallers.py b/openapi_core/unmarshalling/schemas/unmarshallers.py index c275a39..95913db 100644 --- a/openapi_core/unmarshalling/schemas/unmarshallers.py +++ b/openapi_core/unmarshalling/schemas/unmarshallers.py @@ -13,6 +13,7 @@ from openapi_core.schema_validator._types import ( is_object, is_number, is_string, ) from openapi_core.schema_validator._format import oas30_format_checker +from openapi_core.unmarshalling.schemas.enums import UnmarshalContext from openapi_core.unmarshalling.schemas.exceptions import ( UnmarshalError, ValidateError, InvalidSchemaValue, InvalidSchemaFormatValue, @@ -120,9 +121,12 @@ class BooleanUnmarshaller(PrimitiveTypeUnmarshaller): class ComplexUnmarshaller(PrimitiveTypeUnmarshaller): - def __init__(self, formatter, validator, schema, unmarshallers_factory): + def __init__( + self, formatter, validator, schema, unmarshallers_factory, + context=None): super(ComplexUnmarshaller, self).__init__(formatter, validator, schema) self.unmarshallers_factory = unmarshallers_factory + self.context = context class ArrayUnmarshaller(ComplexUnmarshaller): @@ -206,6 +210,10 @@ class ObjectUnmarshaller(ComplexUnmarshaller): properties[prop_name] = prop_value for prop_name, prop in iteritems(all_props): + if self.context == UnmarshalContext.REQUEST and prop.read_only: + continue + if self.context == UnmarshalContext.RESPONSE and prop.write_only: + continue try: prop_value = value[prop_name] except KeyError: diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index ccfe380..cab719d 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -13,6 +13,7 @@ from openapi_core.schema.paths.exceptions import InvalidPath from openapi_core.schema.request_bodies.exceptions import MissingRequestBody from openapi_core.schema.servers.exceptions import InvalidServer from openapi_core.security.exceptions import SecurityError +from openapi_core.unmarshalling.schemas.enums import UnmarshalContext from openapi_core.unmarshalling.schemas.exceptions import ( UnmarshalError, ValidateError, ) @@ -256,7 +257,9 @@ class RequestValidator(object): SchemaUnmarshallersFactory, ) unmarshallers_factory = SchemaUnmarshallersFactory( - self.spec._resolver, self.custom_formatters) + self.spec._resolver, self.custom_formatters, + context=UnmarshalContext.REQUEST, + ) unmarshaller = unmarshallers_factory.create( param_or_media_type.schema) return unmarshaller(value) diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index 241e8d9..0bf9913 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -7,6 +7,7 @@ from openapi_core.schema.responses.exceptions import ( InvalidResponse, MissingResponseContent, ) from openapi_core.schema.servers.exceptions import InvalidServer +from openapi_core.unmarshalling.schemas.enums import UnmarshalContext from openapi_core.unmarshalling.schemas.exceptions import ( UnmarshalError, ValidateError, ) @@ -139,7 +140,9 @@ class ResponseValidator(object): SchemaUnmarshallersFactory, ) unmarshallers_factory = SchemaUnmarshallersFactory( - self.spec._resolver, self.custom_formatters) + self.spec._resolver, self.custom_formatters, + context=UnmarshalContext.RESPONSE, + ) unmarshaller = unmarshallers_factory.create( param_or_media_type.schema) return unmarshaller(value) diff --git a/tests/integration/data/v3.0/read_only_write_only.yaml b/tests/integration/data/v3.0/read_only_write_only.yaml new file mode 100644 index 0000000..be5a06a --- /dev/null +++ b/tests/integration/data/v3.0/read_only_write_only.yaml @@ -0,0 +1,39 @@ +openapi: "3.0.0" +info: + title: Specification Containing readOnly + version: "0.1" +paths: + /users: + post: + operationId: createUser + requestBody: + description: Post data for creating a user + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + default: + description: Create a user + content: + application/json: + schema: + $ref: '#/components/schemas/User' +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int32 + readOnly: true + name: + type: string + hidden: + type: boolean + writeOnly: true \ No newline at end of file diff --git a/tests/integration/schema/test_spec.py b/tests/integration/schema/test_spec.py index 7456589..a2e31f0 100644 --- a/tests/integration/schema/test_spec.py +++ b/tests/integration/schema/test_spec.py @@ -268,5 +268,9 @@ class TestPetstore(object): if not spec.components: return - for _, schema in iteritems(spec.components.schemas): + for schema_name, schema in iteritems(spec.components.schemas): assert type(schema) == Schema + + schema_spec = spec_dict['components']['schemas'][schema_name] + assert schema.read_only == schema_spec.get('readOnly', False) + assert schema.write_only == schema_spec.get('writeOnly', False) diff --git a/tests/integration/validation/test_read_only_write_only.py b/tests/integration/validation/test_read_only_write_only.py new file mode 100644 index 0000000..08cc689 --- /dev/null +++ b/tests/integration/validation/test_read_only_write_only.py @@ -0,0 +1,97 @@ +import json + +import pytest + +from openapi_core.shortcuts import create_spec +from openapi_core.unmarshalling.schemas.exceptions import InvalidSchemaValue +from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.validation.request.validators import RequestValidator +from openapi_core.testing import MockRequest, MockResponse + + +@pytest.fixture +def response_validator(spec): + return ResponseValidator(spec) + + +@pytest.fixture +def request_validator(spec): + return RequestValidator(spec) + + +@pytest.fixture('class') +def spec(factory): + spec_dict = factory.spec_from_file("data/v3.0/read_only_write_only.yaml") + return create_spec(spec_dict) + + +class TestReadOnly(object): + + def test_write_a_read_only_property(self, request_validator): + data = json.dumps({ + 'id': 10, + 'name': "Pedro", + }) + + request = MockRequest(host_url='', method='POST', + path='/users', data=data) + + result = request_validator.validate(request) + + assert type(result.errors[0]) == InvalidSchemaValue + assert result.body is None + + def test_read_only_property_response(self, response_validator): + data = json.dumps({ + 'id': 10, + 'name': "Pedro", + }) + + request = MockRequest(host_url='', method='POST', + path='/users') + + response = MockResponse(data) + + result = response_validator.validate(request, response) + + assert not result.errors + assert result.data == { + 'id': 10, + 'name': "Pedro", + } + + +class TestWriteOnly(object): + + def test_write_only_property(self, request_validator): + data = json.dumps({ + 'name': "Pedro", + 'hidden': False, + }) + + request = MockRequest(host_url='', method='POST', + path='/users', data=data) + + result = request_validator.validate(request) + + assert not result.errors + assert result.body == { + 'name': "Pedro", + 'hidden': False, + } + + def test_read_a_write_only_property(self, response_validator): + data = json.dumps({ + 'id': 10, + 'name': "Pedro", + 'hidden': True, + }) + + request = MockRequest(host_url='', method='POST', + path='/users') + response = MockResponse(data) + + result = response_validator.validate(request, response) + + assert type(result.errors[0]) == InvalidSchemaValue + assert result.data is None diff --git a/tests/unit/unmarshalling/test_unmarshal.py b/tests/unit/unmarshalling/test_unmarshal.py index ae6c88c..e8c7609 100644 --- a/tests/unit/unmarshalling/test_unmarshal.py +++ b/tests/unit/unmarshalling/test_unmarshal.py @@ -8,6 +8,7 @@ from openapi_core.schema.parameters.models import Parameter from openapi_core.schema.schemas.enums import SchemaType from openapi_core.schema.schemas.models import Schema from openapi_core.schema.schemas.types import NoValue +from openapi_core.unmarshalling.schemas.enums import UnmarshalContext from openapi_core.unmarshalling.schemas.exceptions import ( InvalidSchemaFormatValue, InvalidSchemaValue, UnmarshalError, FormatterNotFoundError, @@ -20,9 +21,10 @@ from openapi_core.unmarshalling.schemas.formatters import Formatter @pytest.fixture def unmarshaller_factory(): - def create_unmarshaller(schema, custom_formatters=None): + def create_unmarshaller(schema, custom_formatters=None, context=None): return SchemaUnmarshallersFactory( - custom_formatters=custom_formatters).create(schema) + custom_formatters=custom_formatters, context=context).create( + schema) return create_unmarshaller @@ -429,3 +431,45 @@ class TestSchemaUnmarshallerCall(object): result = unmarshaller_factory(schema)(value) assert result == value + + def test_read_only_properties(self, unmarshaller_factory): + id_property = Schema('integer', read_only=True) + + def properties(): + yield ('id', id_property) + + obj_schema = Schema('object', properties=properties(), required=['id']) + + # readOnly properties may be admitted in a Response context + result = unmarshaller_factory( + obj_schema, context=UnmarshalContext.RESPONSE)({"id": 10}) + assert result == { + 'id': 10, + } + + # readOnly properties are not admitted on a Request context + result = unmarshaller_factory( + obj_schema, context=UnmarshalContext.REQUEST)({"id": 10}) + + assert result == {} + + def test_write_only_properties(self, unmarshaller_factory): + id_property = Schema('integer', write_only=True) + + def properties(): + yield ('id', id_property) + + obj_schema = Schema('object', properties=properties(), required=['id']) + + # readOnly properties may be admitted in a Response context + result = unmarshaller_factory( + obj_schema, context=UnmarshalContext.REQUEST)({"id": 10}) + assert result == { + 'id': 10, + } + + # readOnly properties are not admitted on a Request context + result = unmarshaller_factory( + obj_schema, context=UnmarshalContext.RESPONSE)({"id": 10}) + + assert result == {}