diff --git a/openapi_core/schema/schemas/exceptions.py b/openapi_core/schema/schemas/exceptions.py index 0f777d22..3222a397 100644 --- a/openapi_core/schema/schemas/exceptions.py +++ b/openapi_core/schema/schemas/exceptions.py @@ -15,3 +15,11 @@ class UndefinedSchemaProperty(OpenAPISchemaError): class MissingSchemaProperty(OpenAPISchemaError): pass + + +class NoOneOfSchema(OpenAPISchemaError): + pass + + +class MultipleOneOfSchema(OpenAPISchemaError): + pass diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py index efc4d171..d0fb7cc6 100644 --- a/openapi_core/schema/schemas/factories.py +++ b/openapi_core/schema/schemas/factories.py @@ -27,6 +27,7 @@ def create(self, schema_spec): enum = schema_deref.get('enum', None) deprecated = schema_deref.get('deprecated', False) all_of_spec = schema_deref.get('allOf', None) + one_of_spec = schema_deref.get('oneOf', None) properties = None if properties_spec: @@ -36,6 +37,10 @@ def create(self, schema_spec): if all_of_spec: all_of = map(self.create, all_of_spec) + one_of = [] + if one_of_spec: + one_of = map(self.create, one_of_spec) + items = None if items_spec: items = self._create_items(items_spec) @@ -44,7 +49,7 @@ def create(self, schema_spec): schema_type=schema_type, model=model, properties=properties, items=items, schema_format=schema_format, required=required, default=default, nullable=nullable, enum=enum, - deprecated=deprecated, all_of=all_of, + deprecated=deprecated, all_of=all_of, one_of=one_of, ) @property diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py index cee2545d..d9f1c662 100644 --- a/openapi_core/schema/schemas/models.py +++ b/openapi_core/schema/schemas/models.py @@ -9,6 +9,7 @@ from openapi_core.schema.schemas.enums import SchemaType, SchemaFormat from openapi_core.schema.schemas.exceptions import ( InvalidSchemaValue, UndefinedSchemaProperty, MissingSchemaProperty, + OpenAPISchemaError, NoOneOfSchema, MultipleOneOfSchema, ) from openapi_core.schema.schemas.util import forcebool @@ -27,7 +28,7 @@ class Schema(object): def __init__( 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): + enum=None, deprecated=False, all_of=None, one_of=None): self.type = schema_type and SchemaType(schema_type) self.model = model self.properties = properties and dict(properties) or {} @@ -39,6 +40,10 @@ def __init__( self.enum = enum self.deprecated = deprecated self.all_of = all_of and list(all_of) or [] + self.one_of = one_of and list(one_of) or [] + + self._all_required_properties_cache = None + self._all_optional_properties_cache = None def __getitem__(self, name): return self.properties[name] @@ -52,14 +57,35 @@ def get_all_properties(self): return properties + def get_all_properties_names(self): + all_properties = self.get_all_properties() + return set(all_properties.keys()) + def get_all_required_properties(self): + if self._all_required_properties_cache is None: + self._all_required_properties_cache =\ + self._get_all_required_properties() + + return self._all_required_properties_cache + + def _get_all_required_properties(self): + all_properties = self.get_all_properties() + required = self.get_all_required_properties_names() + + return dict( + (prop_name, val) + for prop_name, val in all_properties.items() + if prop_name in required + ) + + def get_all_required_properties_names(self): required = self.required.copy() for subschema in self.all_of: subschema_req = subschema.get_all_required_properties() required += subschema_req - return required + return set(required) def get_cast_mapping(self): mapping = self.DEFAULT_CAST_CALLABLE_GETTER.copy() @@ -119,27 +145,58 @@ def _unmarshal_object(self, value): raise InvalidSchemaValue( "Value of {0} not an object".format(value)) - all_properties = self.get_all_properties() - all_required_properties = self.get_all_required_properties() - all_properties_keys = all_properties.keys() - value_keys = value.keys() + if self.one_of: + properties = None + for one_of_schema in self.one_of: + try: + found_props = self._unmarshal_properties( + value, one_of_schema) + except OpenAPISchemaError: + pass + else: + if properties is not None: + raise MultipleOneOfSchema( + "Exactly one schema should be valid," + "multiple found") + properties = found_props + + if properties is None: + raise NoOneOfSchema( + "Exactly one valid schema should be valid, None found.") + + else: + properties = self._unmarshal_properties(value) + + return ModelFactory().create(properties, name=self.model) - extra_props = set(value_keys) - set(all_properties_keys) + def _unmarshal_properties(self, value, one_of_schema=None): + all_props = self.get_all_properties() + all_props_names = self.get_all_properties_names() + all_req_props_names = self.get_all_required_properties_names() + if one_of_schema is not None: + all_props.update(one_of_schema.get_all_properties()) + all_props_names |= one_of_schema.\ + get_all_properties_names() + all_req_props_names |= one_of_schema.\ + get_all_required_properties_names() + + value_props_names = value.keys() + extra_props = set(value_props_names) - set(all_props_names) if extra_props: raise UndefinedSchemaProperty( "Undefined properties in schema: {0}".format(extra_props)) properties = {} - for prop_name, prop in iteritems(all_properties): + for prop_name, prop in iteritems(all_props): try: prop_value = value[prop_name] except KeyError: - if prop_name in all_required_properties: + if prop_name in all_req_props_names: raise MissingSchemaProperty( "Missing schema property {0}".format(prop_name)) if not prop.nullable and not prop.default: continue prop_value = prop.default properties[prop_name] = prop.unmarshal(prop_value) - return ModelFactory().create(properties, name=self.model) + return properties diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index ca549057..7afd0710 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -123,6 +123,22 @@ paths: $ref: "#/components/schemas/TagList" default: $ref: "#/components/responses/ErrorResponse" + post: + summary: Create new tag + operationId: createTag + tags: + - tags + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagCreate' + responses: + '200': + description: Null response + default: + $ref: "#/components/responses/ErrorResponse" components: schemas: Address: @@ -163,6 +179,9 @@ components: allOf: - $ref: "#/components/schemas/PetCreatePartOne" - $ref: "#/components/schemas/PetCreatePartTwo" + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Bird" PetCreatePartOne: type: object x-model: PetCreatePartOne @@ -183,6 +202,38 @@ components: $ref: "#/components/schemas/Position" healthy: type: boolean + Bird: + type: object + x-model: Bird + required: + - wings + properties: + wings: + $ref: "#/components/schemas/Wings" + Wings: + type: object + x-model: Wings + required: + - healthy + properties: + healthy: + type: boolean + Cat: + type: object + x-model: Cat + required: + - ears + properties: + ears: + $ref: "#/components/schemas/Ears" + Ears: + type: object + x-model: Ears + required: + - healthy + properties: + healthy: + type: boolean Pets: type: array items: @@ -201,6 +252,13 @@ components: properties: data: $ref: "#/components/schemas/Pet" + TagCreate: + type: object + required: + - name + properties: + name: + type: string TagList: type: array items: diff --git a/tests/integration/test_petstore.py b/tests/integration/test_petstore.py index 3d3d9c68..e9bd86af 100644 --- a/tests/integration/test_petstore.py +++ b/tests/integration/test_petstore.py @@ -15,7 +15,7 @@ from openapi_core.schema.request_bodies.models import RequestBody from openapi_core.schema.responses.models import Response from openapi_core.schema.schemas.exceptions import ( - UndefinedSchemaProperty, MissingSchemaProperty, + UndefinedSchemaProperty, MissingSchemaProperty, NoOneOfSchema, ) from openapi_core.schema.schemas.models import Schema from openapi_core.schema.servers.exceptions import InvalidServer @@ -369,7 +369,7 @@ def test_get_pets_none_value(self, spec): assert body is None - def test_post_pets(self, spec, spec_dict): + def test_post_birds(self, spec, spec_dict): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' pet_name = 'Cat' @@ -386,6 +386,9 @@ def test_post_pets(self, spec, spec_dict): 'city': pet_city, }, 'healthy': pet_healthy, + 'wings': { + 'healthy': pet_healthy, + } } data = json.dumps(data_json) @@ -412,14 +415,14 @@ def test_post_pets(self, spec, spec_dict): assert body.address.city == pet_city assert body.healthy == pet_healthy - def test_post_pets_boolean_string(self, spec, spec_dict): + def test_post_cats(self, spec, spec_dict): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' pet_name = 'Cat' pet_tag = 'cats' pet_street = 'Piekna' pet_city = 'Warsaw' - pet_healthy = 'false' + pet_healthy = False data_json = { 'name': pet_name, 'tag': pet_tag, @@ -429,6 +432,9 @@ def test_post_pets_boolean_string(self, spec, spec_dict): 'city': pet_city, }, 'healthy': pet_healthy, + 'ears': { + 'healthy': pet_healthy, + } } data = json.dumps(data_json) @@ -453,12 +459,29 @@ def test_post_pets_boolean_string(self, spec, spec_dict): assert body.address.__class__.__name__ == address_model assert body.address.street == pet_street assert body.address.city == pet_city - assert body.healthy is False + assert body.healthy == pet_healthy - def test_post_pets_empty_body(self, spec, spec_dict): + def test_post_cats_boolean_string(self, spec, spec_dict): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' - data_json = {} + pet_name = 'Cat' + pet_tag = 'cats' + pet_street = 'Piekna' + pet_city = 'Warsaw' + pet_healthy = 'false' + data_json = { + 'name': pet_name, + 'tag': pet_tag, + 'position': '2', + 'address': { + 'street': pet_street, + 'city': pet_city, + }, + 'healthy': pet_healthy, + 'ears': { + 'healthy': pet_healthy, + } + } data = json.dumps(data_json) request = MockRequest( @@ -470,10 +493,21 @@ def test_post_pets_empty_body(self, spec, spec_dict): assert parameters == {} - with pytest.raises(MissingSchemaProperty): - request.get_body(spec) + body = request.get_body(spec) + + schemas = spec_dict['components']['schemas'] + pet_model = schemas['PetCreate']['x-model'] + address_model = schemas['Address']['x-model'] + assert body.__class__.__name__ == pet_model + assert body.name == pet_name + assert body.tag == pet_tag + assert body.position == 2 + assert body.address.__class__.__name__ == address_model + assert body.address.street == pet_street + assert body.address.city == pet_city + assert body.healthy is False - def test_post_pets_extra_body_properties(self, spec, spec_dict): + def test_post_no_one_of_schema(self, spec, spec_dict): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' pet_name = 'Cat' @@ -493,15 +527,19 @@ def test_post_pets_extra_body_properties(self, spec, spec_dict): assert parameters == {} - with pytest.raises(UndefinedSchemaProperty): + with pytest.raises(NoOneOfSchema): request.get_body(spec) - def test_post_pets_only_required_body(self, spec, spec_dict): + def test_post_cats_only_required_body(self, spec, spec_dict): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' pet_name = 'Cat' + pet_healthy = True data_json = { 'name': pet_name, + 'ears': { + 'healthy': pet_healthy, + } } data = json.dumps(data_json) @@ -523,31 +561,6 @@ def test_post_pets_only_required_body(self, spec, spec_dict): assert not hasattr(body, 'tag') assert not hasattr(body, 'address') - def test_get_pets_wrong_body_type(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_tag = 'cats' - pet_address = 'address text' - data_json = { - 'name': pet_name, - 'tag': pet_tag, - 'address': pet_address, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - ) - - parameters = request.get_parameters(spec) - - assert parameters == {} - - with pytest.raises(InvalidMediaTypeValue): - request.get_body(spec) - def test_post_pets_raises_invalid_mimetype(self, spec): host_url = 'http://petstore.swagger.io/v1' path_pattern = '/v1/pets' @@ -685,3 +698,62 @@ def test_get_tags(self, spec, response_validator): assert response_result.errors == [] assert response_result.data == data_json + + def test_post_tags_extra_body_properties(self, spec, spec_dict): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/tags' + pet_name = 'Dog' + alias = 'kitty' + data_json = { + 'name': pet_name, + 'alias': alias, + } + data = json.dumps(data_json) + + request = MockRequest( + host_url, 'POST', '/tags', + path_pattern=path_pattern, data=data, + ) + + parameters = request.get_parameters(spec) + + assert parameters == {} + + with pytest.raises(UndefinedSchemaProperty): + request.get_body(spec) + + def test_post_tags_empty_body(self, spec, spec_dict): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/tags' + data_json = {} + data = json.dumps(data_json) + + request = MockRequest( + host_url, 'POST', '/tags', + path_pattern=path_pattern, data=data, + ) + + parameters = request.get_parameters(spec) + + assert parameters == {} + + with pytest.raises(MissingSchemaProperty): + request.get_body(spec) + + def test_post_tags_wrong_property_type(self, spec): + host_url = 'http://petstore.swagger.io/v1' + path_pattern = '/v1/tags' + tag_name = 123 + data = json.dumps(tag_name) + + request = MockRequest( + host_url, 'POST', '/tags', + path_pattern=path_pattern, data=data, + ) + + parameters = request.get_parameters(spec) + + assert parameters == {} + + with pytest.raises(InvalidMediaTypeValue): + request.get_body(spec) diff --git a/tests/integration/test_validators.py b/tests/integration/test_validators.py index fe2d714c..d5e6aaed 100644 --- a/tests/integration/test_validators.py +++ b/tests/integration/test_validators.py @@ -123,6 +123,9 @@ def test_post_pets(self, validator, spec_dict): 'address': { 'street': pet_street, 'city': pet_city, + }, + 'ears': { + 'healthy': True, } } data = json.dumps(data_json)