From 34175691e74b3c28ac76db4c3919d8ccfd049416 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Sun, 22 Oct 2023 11:29:24 +0000 Subject: [PATCH] Object caster --- openapi_core/casting/schemas/__init__.py | 64 ++++- openapi_core/casting/schemas/casters.py | 235 +++++++++++++++--- openapi_core/casting/schemas/factories.py | 55 ++-- openapi_core/contrib/falcon/requests.py | 27 +- .../unmarshalling/request/unmarshallers.py | 7 +- openapi_core/unmarshalling/unmarshallers.py | 5 +- openapi_core/validation/request/validators.py | 19 +- .../validation/response/validators.py | 11 + openapi_core/validation/validators.py | 19 +- .../contrib/django/test_django_project.py | 2 +- .../data/v3.0/falconproject/__main__.py | 8 + .../contrib/falcon/test_falcon_project.py | 11 +- tests/integration/data/v3.0/petstore.yaml | 6 + tests/integration/test_petstore.py | 91 ++++++- .../test_request_unmarshaller.py | 6 +- .../validation/test_request_validators.py | 6 +- tests/unit/casting/test_schema_casters.py | 29 ++- tests/unit/test_shortcuts.py | 1 + 18 files changed, 507 insertions(+), 95 deletions(-) diff --git a/openapi_core/casting/schemas/__init__.py b/openapi_core/casting/schemas/__init__.py index 5af6f208..18b1a9e3 100644 --- a/openapi_core/casting/schemas/__init__.py +++ b/openapi_core/casting/schemas/__init__.py @@ -1,5 +1,65 @@ +from collections import OrderedDict + +from openapi_core.casting.schemas.casters import ArrayCaster +from openapi_core.casting.schemas.casters import BooleanCaster +from openapi_core.casting.schemas.casters import IntegerCaster +from openapi_core.casting.schemas.casters import NumberCaster +from openapi_core.casting.schemas.casters import ObjectCaster +from openapi_core.casting.schemas.casters import PrimitiveCaster +from openapi_core.casting.schemas.casters import TypesCaster from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.validation.schemas import ( + oas30_read_schema_validators_factory, +) +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, +) +from openapi_core.validation.schemas import oas31_schema_validators_factory + +__all__ = [ + "oas30_write_schema_casters_factory", + "oas30_read_schema_casters_factory", + "oas31_schema_casters_factory", +] + +oas30_casters_dict = OrderedDict( + [ + ("object", ObjectCaster), + ("array", ArrayCaster), + ("boolean", BooleanCaster), + ("integer", IntegerCaster), + ("number", NumberCaster), + ("string", PrimitiveCaster), + ] +) +oas31_casters_dict = oas30_casters_dict.copy() +oas31_casters_dict.update( + { + "null": PrimitiveCaster, + } +) + +oas30_types_caster = TypesCaster( + oas30_casters_dict, + PrimitiveCaster, +) +oas31_types_caster = TypesCaster( + oas31_casters_dict, + PrimitiveCaster, + multi=PrimitiveCaster, +) + +oas30_write_schema_casters_factory = SchemaCastersFactory( + oas30_write_schema_validators_factory, + oas30_types_caster, +) -__all__ = ["schema_casters_factory"] +oas30_read_schema_casters_factory = SchemaCastersFactory( + oas30_read_schema_validators_factory, + oas30_types_caster, +) -schema_casters_factory = SchemaCastersFactory() +oas31_schema_casters_factory = SchemaCastersFactory( + oas31_schema_validators_factory, + oas31_types_caster, +) diff --git a/openapi_core/casting/schemas/casters.py b/openapi_core/casting/schemas/casters.py index b62077fc..64cc6391 100644 --- a/openapi_core/casting/schemas/casters.py +++ b/openapi_core/casting/schemas/casters.py @@ -1,67 +1,238 @@ from typing import TYPE_CHECKING from typing import Any from typing import Callable +from typing import Generic +from typing import Iterable from typing import List +from typing import Mapping +from typing import Optional +from typing import Type +from typing import TypeVar +from typing import Union from jsonschema_path import SchemaPath from openapi_core.casting.schemas.datatypes import CasterCallable from openapi_core.casting.schemas.exceptions import CastError +from openapi_core.schema.schemas import get_properties +from openapi_core.util import forcebool +from openapi_core.validation.schemas.validators import SchemaValidator -if TYPE_CHECKING: - from openapi_core.casting.schemas.factories import SchemaCastersFactory - -class BaseSchemaCaster: - def __init__(self, schema: SchemaPath): +class PrimitiveCaster: + def __init__( + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + schema_caster: "SchemaCaster", + ): self.schema = schema + self.schema_validator = schema_validator + self.schema_caster = schema_caster def __call__(self, value: Any) -> Any: - if value is None: - return value + return value - return self.cast(value) - def cast(self, value: Any) -> Any: - raise NotImplementedError +PrimitiveType = TypeVar("PrimitiveType") -class CallableSchemaCaster(BaseSchemaCaster): - def __init__(self, schema: SchemaPath, caster_callable: CasterCallable): - super().__init__(schema) - self.caster_callable = caster_callable +class PrimitiveTypeCaster(Generic[PrimitiveType], PrimitiveCaster): + primitive_type: Type[PrimitiveType] = NotImplemented + + def __call__(self, value: Union[str, bytes]) -> Any: + self.validate(value) + + return self.primitive_type(value) # type: ignore [call-arg] + + def validate(self, value: Any) -> None: + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + # if not isinstance(value, (str, bytes)): + # raise ValueError("should cast only from string or bytes") + pass + + +class IntegerCaster(PrimitiveTypeCaster[int]): + primitive_type = int + + +class NumberCaster(PrimitiveTypeCaster[float]): + primitive_type = float + + +class BooleanCaster(PrimitiveTypeCaster[bool]): + primitive_type = bool + + def __call__(self, value: Union[str, bytes]) -> Any: + self.validate(value) + + return self.primitive_type(forcebool(value)) + + def validate(self, value: Any) -> None: + super().validate(value) + + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + if isinstance(value, bool): + return + + if value.lower() not in ["false", "true"]: + raise ValueError("not a boolean format") + + +class ArrayCaster(PrimitiveCaster): + @property + def items_caster(self) -> "SchemaCaster": + # sometimes we don't have any schema i.e. free-form objects + items_schema = self.schema.get("items", SchemaPath.from_dict({})) + return self.schema_caster.evolve(items_schema) + + def __call__(self, value: Any) -> List[Any]: + # str and bytes are not arrays according to the OpenAPI spec + if isinstance(value, (str, bytes)) or not isinstance(value, Iterable): + raise CastError(value, self.schema["type"]) - def cast(self, value: Any) -> Any: try: - return self.caster_callable(value) + return list(map(self.items_caster.cast, value)) except (ValueError, TypeError): raise CastError(value, self.schema["type"]) -class DummyCaster(BaseSchemaCaster): - def cast(self, value: Any) -> Any: +class ObjectCaster(PrimitiveCaster): + def __call__(self, value: Any) -> Any: + return self._cast_proparties(value) + + def evolve(self, schema: SchemaPath) -> "ObjectCaster": + cls = self.__class__ + + return cls( + schema, + self.schema_validator.evolve(schema), + self.schema_caster.evolve(schema), + ) + + def _cast_proparties(self, value: Any, schema_only: bool = False) -> Any: + if not isinstance(value, dict): + raise CastError(value, self.schema["type"]) + + all_of_schemas = self.schema_validator.iter_all_of_schemas(value) + for all_of_schema in all_of_schemas: + all_of_properties = self.evolve(all_of_schema)._cast_proparties( + value, schema_only=True + ) + value.update(all_of_properties) + + for prop_name, prop_schema in get_properties(self.schema).items(): + try: + prop_value = value[prop_name] + except KeyError: + continue + value[prop_name] = self.schema_caster.evolve(prop_schema).cast( + prop_value + ) + + if schema_only: + return value + + additional_properties = self.schema.getkey( + "additionalProperties", True + ) + if additional_properties is not False: + # free-form object + if additional_properties is True: + additional_prop_schema = SchemaPath.from_dict( + {"nullable": True} + ) + # defined schema + else: + additional_prop_schema = self.schema / "additionalProperties" + additional_prop_caster = self.schema_caster.evolve( + additional_prop_schema + ) + for prop_name, prop_value in value.items(): + if prop_name in value: + continue + value[prop_name] = additional_prop_caster.cast(prop_value) + return value -class ComplexCaster(BaseSchemaCaster): +class TypesCaster: + casters: Mapping[str, Type[PrimitiveCaster]] = {} + multi: Optional[Type[PrimitiveCaster]] = None + def __init__( - self, schema: SchemaPath, casters_factory: "SchemaCastersFactory" + self, + casters: Mapping[str, Type[PrimitiveCaster]], + default: Type[PrimitiveCaster], + multi: Optional[Type[PrimitiveCaster]] = None, ): - super().__init__(schema) - self.casters_factory = casters_factory + self.casters = casters + self.default = default + self.multi = multi + + def get_caster( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> Type["PrimitiveCaster"]: + if schema_type is None: + return self.default + if isinstance(schema_type, Iterable) and not isinstance( + schema_type, str + ): + if self.multi is None: + raise TypeError("caster does not accept multiple types") + return self.multi + + return self.casters[schema_type] + + +class SchemaCaster: + def __init__( + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + types_caster: TypesCaster, + ): + self.schema = schema + self.schema_validator = schema_validator + self.types_caster = types_caster -class ArrayCaster(ComplexCaster): - @property - def items_caster(self) -> BaseSchemaCaster: - return self.casters_factory.create(self.schema / "items") + def cast(self, value: Any) -> Any: + # skip casting for nullable in OpenAPI 3.0 + if value is None and self.schema.getkey("nullable", False): + return value - def cast(self, value: Any) -> List[Any]: - # str and bytes are not arrays according to the OpenAPI spec - if isinstance(value, (str, bytes)): - raise CastError(value, self.schema["type"]) + schema_type = self.schema.getkey("type") + + type_caster = self.get_type_caster(schema_type) + + if value is None: + return value try: - return list(map(self.items_caster, value)) + return type_caster(value) except (ValueError, TypeError): - raise CastError(value, self.schema["type"]) + raise CastError(value, schema_type) + + def get_type_caster( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> PrimitiveCaster: + caster_cls = self.types_caster.get_caster(schema_type) + return caster_cls( + self.schema, + self.schema_validator, + self, + ) + + def evolve(self, schema: SchemaPath) -> "SchemaCaster": + cls = self.__class__ + + return cls( + schema, + self.schema_validator.evolve(schema), + self.types_caster, + ) diff --git a/openapi_core/casting/schemas/factories.py b/openapi_core/casting/schemas/factories.py index ea4638fa..3cb49cd8 100644 --- a/openapi_core/casting/schemas/factories.py +++ b/openapi_core/casting/schemas/factories.py @@ -1,38 +1,35 @@ from typing import Dict +from typing import Optional from jsonschema_path import SchemaPath -from openapi_core.casting.schemas.casters import ArrayCaster -from openapi_core.casting.schemas.casters import BaseSchemaCaster -from openapi_core.casting.schemas.casters import CallableSchemaCaster -from openapi_core.casting.schemas.casters import DummyCaster +from openapi_core.casting.schemas.casters import SchemaCaster +from openapi_core.casting.schemas.casters import TypesCaster from openapi_core.casting.schemas.datatypes import CasterCallable from openapi_core.util import forcebool +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory class SchemaCastersFactory: - DUMMY_CASTERS = [ - "string", - "object", - "any", - ] - PRIMITIVE_CASTERS: Dict[str, CasterCallable] = { - "integer": int, - "number": float, - "boolean": forcebool, - } - COMPLEX_CASTERS = { - "array": ArrayCaster, - } - - def create(self, schema: SchemaPath) -> BaseSchemaCaster: - schema_type = schema.getkey("type", "any") - - if schema_type in self.DUMMY_CASTERS: - return DummyCaster(schema) - - if schema_type in self.PRIMITIVE_CASTERS: - caster_callable = self.PRIMITIVE_CASTERS[schema_type] - return CallableSchemaCaster(schema, caster_callable) - - return ArrayCaster(schema, self) + def __init__( + self, + schema_validators_factory: SchemaValidatorsFactory, + types_caster: TypesCaster, + ): + self.schema_validators_factory = schema_validators_factory + self.types_caster = types_caster + + def create( + self, + schema: SchemaPath, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + ) -> SchemaCaster: + schema_validator = self.schema_validators_factory.create( + schema, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + ) + + return SchemaCaster(schema, schema_validator, self.types_caster) diff --git a/openapi_core/contrib/falcon/requests.py b/openapi_core/contrib/falcon/requests.py index 2e71e961..7ebf7274 100644 --- a/openapi_core/contrib/falcon/requests.py +++ b/openapi_core/contrib/falcon/requests.py @@ -1,4 +1,5 @@ """OpenAPI core contrib falcon responses module""" +import warnings from json import dumps from typing import Any from typing import Dict @@ -49,11 +50,31 @@ def method(self) -> str: @property def body(self) -> Optional[str]: + # Support falcon-jsonify. + if hasattr(self.request, "json"): + return dumps(self.request.json) + + # Falcon doesn't store raw request stream. + # That's why we need to revert serialized data media = self.request.get_media( - default_when_empty=self.default_when_empty + default_when_empty=self.default_when_empty, ) - # Support falcon-jsonify. - return dumps(getattr(self.request, "json", media)) + handler, _, _ = self.request.options.media_handlers._resolve( + self.request.content_type, self.request.options.default_media_type + ) + try: + body = handler.serialize( + media, content_type=self.request.content_type + ) + # multipart form serialization is not supported + except NotImplementedError: + warnings.warn( + f"body serialization for {self.request.content_type} not supported" + ) + return None + else: + assert isinstance(body, bytes) + return body.decode("utf-8") @property def content_type(self) -> str: diff --git a/openapi_core/unmarshalling/request/unmarshallers.py b/openapi_core/unmarshalling/request/unmarshallers.py index 4d19113d..10f69b69 100644 --- a/openapi_core/unmarshalling/request/unmarshallers.py +++ b/openapi_core/unmarshalling/request/unmarshallers.py @@ -3,7 +3,6 @@ from jsonschema_path import SchemaPath from openapi_spec_validator.validation.types import SpecValidatorType -from openapi_core.casting.schemas import schema_casters_factory from openapi_core.casting.schemas.factories import SchemaCastersFactory from openapi_core.deserializing.media_types import ( media_type_deserializers_factory, @@ -85,9 +84,9 @@ def __init__( self, spec: SchemaPath, base_url: Optional[str] = None, - schema_casters_factory: SchemaCastersFactory = schema_casters_factory, style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, schema_validators_factory: Optional[SchemaValidatorsFactory] = None, spec_validator_cls: Optional[SpecValidatorType] = None, format_validators: Optional[FormatValidatorsDict] = None, @@ -106,9 +105,9 @@ def __init__( self, spec, base_url=base_url, - schema_casters_factory=schema_casters_factory, style_deserializers_factory=style_deserializers_factory, media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, schema_validators_factory=schema_validators_factory, spec_validator_cls=spec_validator_cls, format_validators=format_validators, @@ -122,9 +121,9 @@ def __init__( self, spec, base_url=base_url, - schema_casters_factory=schema_casters_factory, style_deserializers_factory=style_deserializers_factory, media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, schema_validators_factory=schema_validators_factory, spec_validator_cls=spec_validator_cls, format_validators=format_validators, diff --git a/openapi_core/unmarshalling/unmarshallers.py b/openapi_core/unmarshalling/unmarshallers.py index 858b36a2..9869b9c7 100644 --- a/openapi_core/unmarshalling/unmarshallers.py +++ b/openapi_core/unmarshalling/unmarshallers.py @@ -6,7 +6,6 @@ from jsonschema_path import SchemaPath from openapi_spec_validator.validation.types import SpecValidatorType -from openapi_core.casting.schemas import schema_casters_factory from openapi_core.casting.schemas.factories import SchemaCastersFactory from openapi_core.deserializing.media_types import ( media_type_deserializers_factory, @@ -39,9 +38,9 @@ def __init__( self, spec: SchemaPath, base_url: Optional[str] = None, - schema_casters_factory: SchemaCastersFactory = schema_casters_factory, style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, schema_validators_factory: Optional[SchemaValidatorsFactory] = None, spec_validator_cls: Optional[SpecValidatorType] = None, format_validators: Optional[FormatValidatorsDict] = None, @@ -62,9 +61,9 @@ def __init__( super().__init__( spec, base_url=base_url, - schema_casters_factory=schema_casters_factory, style_deserializers_factory=style_deserializers_factory, media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, schema_validators_factory=schema_validators_factory, spec_validator_cls=spec_validator_cls, format_validators=format_validators, diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index 9394c689..1781fd2b 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -10,7 +10,8 @@ from openapi_spec_validator import OpenAPIV31SpecValidator from openapi_spec_validator.validation.types import SpecValidatorType -from openapi_core.casting.schemas import schema_casters_factory +from openapi_core.casting.schemas import oas30_write_schema_casters_factory +from openapi_core.casting.schemas import oas31_schema_casters_factory from openapi_core.casting.schemas.factories import SchemaCastersFactory from openapi_core.datatypes import Parameters from openapi_core.datatypes import RequestParameters @@ -69,9 +70,9 @@ def __init__( self, spec: SchemaPath, base_url: Optional[str] = None, - schema_casters_factory: SchemaCastersFactory = schema_casters_factory, style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, schema_validators_factory: Optional[SchemaValidatorsFactory] = None, spec_validator_cls: Optional[SpecValidatorType] = None, format_validators: Optional[FormatValidatorsDict] = None, @@ -84,9 +85,9 @@ def __init__( super().__init__( spec, base_url=base_url, - schema_casters_factory=schema_casters_factory, style_deserializers_factory=style_deserializers_factory, media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, schema_validators_factory=schema_validators_factory, spec_validator_cls=spec_validator_cls, format_validators=format_validators, @@ -396,64 +397,76 @@ def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: class V30RequestBodyValidator(APICallRequestBodyValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory schema_validators_factory = oas30_write_schema_validators_factory class V30RequestParametersValidator(APICallRequestParametersValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory schema_validators_factory = oas30_write_schema_validators_factory class V30RequestSecurityValidator(APICallRequestSecurityValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory schema_validators_factory = oas30_write_schema_validators_factory class V30RequestValidator(APICallRequestValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory schema_validators_factory = oas30_write_schema_validators_factory class V31RequestBodyValidator(APICallRequestBodyValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31RequestParametersValidator(APICallRequestParametersValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31RequestSecurityValidator(APICallRequestSecurityValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31RequestValidator(APICallRequestValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory path_finder_cls = WebhookPathFinder class V31WebhookRequestBodyValidator(WebhookRequestBodyValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory path_finder_cls = WebhookPathFinder class V31WebhookRequestParametersValidator(WebhookRequestParametersValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory path_finder_cls = WebhookPathFinder class V31WebhookRequestSecurityValidator(WebhookRequestSecurityValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory path_finder_cls = WebhookPathFinder class V31WebhookRequestValidator(WebhookRequestValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory path_finder_cls = WebhookPathFinder diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index c80d052f..c67de77b 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -10,6 +10,8 @@ from openapi_spec_validator import OpenAPIV30SpecValidator from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_core.casting.schemas import oas30_read_schema_casters_factory +from openapi_core.casting.schemas import oas31_schema_casters_factory from openapi_core.exceptions import OpenAPIError from openapi_core.protocols import Request from openapi_core.protocols import Response @@ -344,44 +346,53 @@ def iter_errors( class V30ResponseDataValidator(APICallResponseDataValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory schema_validators_factory = oas30_read_schema_validators_factory class V30ResponseHeadersValidator(APICallResponseHeadersValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory schema_validators_factory = oas30_read_schema_validators_factory class V30ResponseValidator(APICallResponseValidator): spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory schema_validators_factory = oas30_read_schema_validators_factory class V31ResponseDataValidator(APICallResponseDataValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31ResponseHeadersValidator(APICallResponseHeadersValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31ResponseValidator(APICallResponseValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31WebhookResponseDataValidator(WebhookResponseDataValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31WebhookResponseHeadersValidator(WebhookResponseHeadersValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory class V31WebhookResponseValidator(WebhookResponseValidator): spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory schema_validators_factory = oas31_schema_validators_factory diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 3494dad1..ad82705e 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -11,7 +11,6 @@ from jsonschema_path import SchemaPath from openapi_spec_validator.validation.types import SpecValidatorType -from openapi_core.casting.schemas import schema_casters_factory from openapi_core.casting.schemas.factories import SchemaCastersFactory from openapi_core.deserializing.media_types import ( media_type_deserializers_factory, @@ -42,6 +41,7 @@ class BaseValidator: + schema_casters_factory: SchemaCastersFactory = NotImplemented schema_validators_factory: SchemaValidatorsFactory = NotImplemented spec_validator_cls: Optional[SpecValidatorType] = None @@ -49,9 +49,9 @@ def __init__( self, spec: SchemaPath, base_url: Optional[str] = None, - schema_casters_factory: SchemaCastersFactory = schema_casters_factory, style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, schema_validators_factory: Optional[SchemaValidatorsFactory] = None, spec_validator_cls: Optional[SpecValidatorType] = None, format_validators: Optional[FormatValidatorsDict] = None, @@ -63,7 +63,11 @@ def __init__( self.spec = spec self.base_url = base_url - self.schema_casters_factory = schema_casters_factory + self.schema_casters_factory = ( + schema_casters_factory or self.schema_casters_factory + ) + if self.schema_casters_factory is NotImplemented: + raise NotImplementedError("schema_casters_factory is not assigned") self.style_deserializers_factory = style_deserializers_factory self.media_type_deserializers_factory = ( media_type_deserializers_factory @@ -133,7 +137,7 @@ def _deserialise_style( def _cast(self, schema: SchemaPath, value: Any) -> Any: caster = self.schema_casters_factory.create(schema) - return caster(value) + return caster.cast(value) def _validate_schema(self, schema: SchemaPath, value: Any) -> None: validator = self.schema_validators_factory.create( @@ -230,12 +234,15 @@ def _get_content_schema_value_and_schema( deserialised = self._deserialise_media_type( media_type, mime_type, parameters, raw ) - casted = self._cast(media_type, deserialised) if "schema" not in media_type: - return casted, None + return deserialised, None schema = media_type / "schema" + # cast for urlencoded content + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + casted = self._cast(schema, deserialised) return casted, schema def _get_content_and_schema( diff --git a/tests/integration/contrib/django/test_django_project.py b/tests/integration/contrib/django/test_django_project.py index 0cb93529..a9c3b90c 100644 --- a/tests/integration/contrib/django/test_django_project.py +++ b/tests/integration/contrib/django/test_django_project.py @@ -184,7 +184,7 @@ def test_post_media_type_invalid(self, client): "title": ( "Content for the following mimetype not found: " "text/html. " - "Valid mimetypes: ['application/json', 'text/plain']" + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'text/plain']" ), } ] diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py index 420601d3..ae71fcf0 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py @@ -1,11 +1,19 @@ from falcon import App +from falcon import media from falconproject.openapi import openapi_middleware from falconproject.pets.resources import PetDetailResource from falconproject.pets.resources import PetListResource from falconproject.pets.resources import PetPhotoResource +extra_handlers = { + "application/vnd.api+json": media.JSONHandler(), +} + app = App(middleware=[openapi_middleware]) +app.req_options.media_handlers.update(extra_handlers) +app.resp_options.media_handlers.update(extra_handlers) + pet_list_resource = PetListResource() pet_detail_resource = PetDetailResource() pet_photo_resource = PetPhotoResource() diff --git a/tests/integration/contrib/falcon/test_falcon_project.py b/tests/integration/contrib/falcon/test_falcon_project.py index 4afeb50b..6984acbe 100644 --- a/tests/integration/contrib/falcon/test_falcon_project.py +++ b/tests/integration/contrib/falcon/test_falcon_project.py @@ -145,21 +145,24 @@ def test_post_required_header_param_missing(self, client): def test_post_media_type_invalid(self, client): cookies = {"user": 1} - data = "data" + data_json = { + "data": "", + } # noly 3 media types are supported by falcon by default: # json, multipart and urlencoded - content_type = MEDIA_URLENCODED + content_type = "application/vnd.api+json" headers = { "Authorization": "Basic testuser", "Api-Key": self.api_key_encoded, "Content-Type": content_type, } + body = dumps(data_json) response = client.simulate_post( "/v1/pets", host="staging.gigantic-server.com", headers=headers, - body=data, + body=body, cookies=cookies, protocol="https", ) @@ -175,7 +178,7 @@ def test_post_media_type_invalid(self, client): "title": ( "Content for the following mimetype not found: " f"{content_type}. " - "Valid mimetypes: ['application/json', 'text/plain']" + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'text/plain']" ), } ] diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index 43b27398..d26816ac 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -147,6 +147,9 @@ paths: example: name: "Pet" wings: [] + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PetCreate' text/plain: {} responses: '201': @@ -267,6 +270,9 @@ paths: application/json: schema: $ref: '#/components/schemas/TagCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TagCreate' responses: '200': description: Null response diff --git a/tests/integration/test_petstore.py b/tests/integration/test_petstore.py index 88fb4ba7..20569b2a 100644 --- a/tests/integration/test_petstore.py +++ b/tests/integration/test_petstore.py @@ -2,6 +2,7 @@ from base64 import b64encode from dataclasses import is_dataclass from datetime import datetime +from urllib.parse import urlencode from uuid import UUID import pytest @@ -522,7 +523,7 @@ def test_get_pets_allow_empty_value(self, spec): host_url = "http://petstore.swagger.io/v1" path_pattern = "/v1/pets" query_params = { - "limit": 20, + "limit": "20", "search": "", } @@ -889,6 +890,92 @@ def test_post_cats_boolean_string(self, spec, spec_dict): assert result.body.address.city == pet_city assert result.body.healthy is False + @pytest.mark.xfail( + reason="urlencoded object with oneof not supported", + strict=True, + ) + def test_post_urlencoded(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + 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, + "wings": { + "healthy": pet_healthy, + }, + } + data = urlencode(data_json) + headers = { + "api-key": self.api_key_encoded, + } + userdata = { + "name": "user1", + } + userdata_json = json.dumps(userdata) + cookies = { + "user": "123", + "userdata": userdata_json, + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + content_type="application/x-www-form-urlencoded", + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert is_dataclass(result.parameters.cookie["userdata"]) + assert ( + result.parameters.cookie["userdata"].__class__.__name__ + == "Userdata" + ) + assert result.parameters.cookie["userdata"].name == "user1" + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + assert result.body.healthy == pet_healthy + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestSecurityUnmarshaller, + ) + + assert result.security == {} + def test_post_no_one_of_schema(self, spec): host_url = "https://staging.gigantic-server.com/v1" path_pattern = "/v1/pets" @@ -1506,7 +1593,7 @@ def test_post_tags_wrong_property_type(self, spec): spec=spec, cls=V30RequestBodyValidator, ) - assert type(exc_info.value.__cause__) is InvalidSchemaValue + assert type(exc_info.value.__cause__) is CastError def test_post_tags_additional_properties(self, spec): host_url = "http://petstore.swagger.io/v1" diff --git a/tests/integration/unmarshalling/test_request_unmarshaller.py b/tests/integration/unmarshalling/test_request_unmarshaller.py index 09cc0301..a09675e8 100644 --- a/tests/integration/unmarshalling/test_request_unmarshaller.py +++ b/tests/integration/unmarshalling/test_request_unmarshaller.py @@ -198,7 +198,11 @@ def test_invalid_content_type(self, request_unmarshaller): assert type(result.errors[0]) == RequestBodyValidationError assert result.errors[0].__cause__ == MediaTypeNotFound( mimetype="text/csv", - availableMimetypes=["application/json", "text/plain"], + availableMimetypes=[ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", + ], ) assert result.body is None assert result.parameters == Parameters( diff --git a/tests/integration/validation/test_request_validators.py b/tests/integration/validation/test_request_validators.py index 175fe48d..61ad611a 100644 --- a/tests/integration/validation/test_request_validators.py +++ b/tests/integration/validation/test_request_validators.py @@ -112,7 +112,11 @@ def test_media_type_not_found(self, request_validator): assert exc_info.value.__cause__ == MediaTypeNotFound( mimetype="text/csv", - availableMimetypes=["application/json", "text/plain"], + availableMimetypes=[ + "application/json", + "application/x-www-form-urlencoded", + "text/plain", + ], ) def test_valid(self, request_validator): diff --git a/tests/unit/casting/test_schema_casters.py b/tests/unit/casting/test_schema_casters.py index cb14a23a..39c0235c 100644 --- a/tests/unit/casting/test_schema_casters.py +++ b/tests/unit/casting/test_schema_casters.py @@ -1,18 +1,39 @@ import pytest from jsonschema_path import SchemaPath +from openapi_core.casting.schemas import oas31_schema_casters_factory from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.casting.schemas.factories import SchemaCastersFactory class TestSchemaCaster: @pytest.fixture def caster_factory(self): def create_caster(schema): - return SchemaCastersFactory().create(schema) + return oas31_schema_casters_factory.create(schema) return create_caster + @pytest.mark.parametrize( + "schema_type,value,expected", + [ + ("integer", "2", 2), + ("number", "3.14", 3.14), + ("boolean", "false", False), + ("boolean", "true", True), + ], + ) + def test_primitive_flat( + self, caster_factory, schema_type, value, expected + ): + spec = { + "type": schema_type, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(value) + + assert result == expected + def test_array_invalid_type(self, caster_factory): spec = { "type": "array", @@ -24,7 +45,7 @@ def test_array_invalid_type(self, caster_factory): value = ["test", "test2"] with pytest.raises(CastError): - caster_factory(schema)(value) + caster_factory(schema).cast(value) @pytest.mark.parametrize("value", [3.14, "foo", b"foo"]) def test_array_invalid_value(self, value, caster_factory): @@ -39,4 +60,4 @@ def test_array_invalid_value(self, value, caster_factory): with pytest.raises( CastError, match=f"Failed to cast value to array type: {value}" ): - caster_factory(schema)(value) + caster_factory(schema).cast(value) diff --git a/tests/unit/test_shortcuts.py b/tests/unit/test_shortcuts.py index 1d83c569..1d69c69e 100644 --- a/tests/unit/test_shortcuts.py +++ b/tests/unit/test_shortcuts.py @@ -48,6 +48,7 @@ class MockClass: spec_validator_cls = None + schema_casters_factory = None schema_validators_factory = None schema_unmarshallers_factory = None