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
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: