From 2a330f35e0df563cd28b3c65b19b44e95ebc1b9a Mon Sep 17 00:00:00 2001 From: p1c2u Date: Fri, 20 Jan 2023 05:54:59 +0000 Subject: [PATCH] Webhooks support --- README.rst | 28 +- docs/integrations.rst | 11 + docs/usage.rst | 24 +- openapi_core/__init__.py | 10 +- openapi_core/contrib/requests/__init__.py | 4 + openapi_core/contrib/requests/requests.py | 15 +- openapi_core/security/providers.py | 16 +- openapi_core/templating/paths/datatypes.py | 8 +- openapi_core/templating/paths/finders.py | 97 +++-- openapi_core/validation/processors.py | 4 +- openapi_core/validation/request/__init__.py | 18 + openapi_core/validation/request/exceptions.py | 6 - openapi_core/validation/request/protocols.py | 62 +++- openapi_core/validation/request/proxies.py | 6 +- openapi_core/validation/request/validators.py | 332 +++++++++++------ openapi_core/validation/response/__init__.py | 14 + .../validation/response/exceptions.py | 3 - openapi_core/validation/response/protocols.py | 14 + openapi_core/validation/response/proxies.py | 6 +- .../validation/response/validators.py | 347 ++++++++++++------ openapi_core/validation/shortcuts.py | 78 +++- openapi_core/validation/validators.py | 39 +- .../requests/data/v3.0/requests_factory.yaml | 36 +- .../requests/test_requests_validation.py | 82 ++++- tests/unit/security/test_providers.py | 2 +- tests/unit/templating/test_paths_finders.py | 4 +- tests/unit/validation/test_shortcuts.py | 180 ++++++++- 27 files changed, 1101 insertions(+), 345 deletions(-) diff --git a/README.rst b/README.rst index e74cfcfb..7f2d759a 100644 --- a/README.rst +++ b/README.rst @@ -80,6 +80,17 @@ Use ``validate_request`` function to validate request against a given spec. # raise error if request is invalid result = validate_request(request, spec=spec) +Request object should implement OpenAPI Request protocol (See `Integrations `__). + +Use the same function to validate webhook request against a given spec. + +.. code-block:: python + + # raise error if request is invalid + result = validate_request(webhook_request, spec=spec) + +Webhook request object should implement OpenAPI WebhookRequest protocol (See `Integrations `__). + Retrieve request data from validation result .. code-block:: python @@ -95,8 +106,6 @@ Retrieve request data from validation result # get security data validated_security = result.security -Request object should implement OpenAPI Request protocol (See `Integrations `__). - Response ******** @@ -109,7 +118,16 @@ Use ``validate_response`` function to validate response against a given spec. # raise error if response is invalid result = validate_response(request, response, spec=spec) -and unmarshal response data from validation result +Response object should implement OpenAPI Response protocol (See `Integrations `__). + +Use the same function to validate response from webhook request against a given spec. + +.. code-block:: python + + # raise error if request is invalid + result = validate_response(webhook_request, response, spec=spec) + +Retrieve response data from validation result .. code-block:: python @@ -119,12 +137,10 @@ and unmarshal response data from validation result # get data validated_data = result.data -Response object should implement OpenAPI Response protocol (See `Integrations `__). - In order to explicitly validate a: * OpenAPI 3.0 spec, import ``V30RequestValidator`` or ``V30ResponseValidator`` -* OpenAPI 3.1 spec, import ``V31RequestValidator`` or ``V31ResponseValidator`` +* OpenAPI 3.1 spec, import ``V31RequestValidator`` or ``V31ResponseValidator`` or ``V31WebhookRequestValidator`` or ``V31WebhookResponseValidator`` .. code:: python diff --git a/docs/integrations.rst b/docs/integrations.rst index ad34931a..b6e660c3 100644 --- a/docs/integrations.rst +++ b/docs/integrations.rst @@ -240,6 +240,17 @@ You can use ``RequestsOpenAPIResponse`` as a Requests response factory: result = validate_response(openapi_request, openapi_response, spec=spec) +You can use ``RequestsOpenAPIWebhookRequest`` as a Requests webhook request factory: + +.. code-block:: python + + from openapi_core import validate_request + from openapi_core.contrib.requests import RequestsOpenAPIWebhookRequest + + openapi_webhook_request = RequestsOpenAPIWebhookRequest(requests_request, "my_webhook") + result = validate_request(openapi_webhook_request, spec=spec) + + Starlette --------- diff --git a/docs/usage.rst b/docs/usage.rst index 8d9a3bb7..b952838c 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -23,6 +23,17 @@ Use ``validate_request`` function to validate request against a given spec. By d # raise error if request is invalid result = validate_request(request, spec=spec) +Request object should implement OpenAPI Request protocol (See :doc:`integrations`). + +Use the same function to validate webhook request against a given spec. + +.. code-block:: python + + # raise error if request is invalid + result = validate_request(webhook_request, spec=spec) + +Webhook request object should implement OpenAPI WebhookRequest protocol (See :doc:`integrations`). + Retrieve validated and unmarshalled request data from validation result .. code-block:: python @@ -38,8 +49,6 @@ Retrieve validated and unmarshalled request data from validation result # get security data validated_security = result.security -Request object should implement OpenAPI Request protocol (See :doc:`integrations`). - Response -------- @@ -52,6 +61,15 @@ Use ``validate_response`` function to validate response against a given spec. By # raise error if response is invalid result = validate_response(request, response, spec=spec) +Response object should implement OpenAPI Response protocol (See :doc:`integrations`). + +Use the same function to validate response from webhook request against a given spec. + +.. code-block:: python + + # raise error if request is invalid + result = validate_response(webhook_request, response, spec=spec) + Retrieve validated and unmarshalled response data from validation result .. code-block:: python @@ -62,8 +80,6 @@ Retrieve validated and unmarshalled response data from validation result # get data validated_data = result.data -Response object should implement OpenAPI Response protocol (See :doc:`integrations`). - Security -------- diff --git a/openapi_core/__init__.py b/openapi_core/__init__.py index f0e69c68..9baa9fb7 100644 --- a/openapi_core/__init__.py +++ b/openapi_core/__init__.py @@ -1,8 +1,10 @@ """OpenAPI core module""" from openapi_core.spec import Spec from openapi_core.validation.request import V3RequestValidator +from openapi_core.validation.request import V3WebhookRequestValidator from openapi_core.validation.request import V30RequestValidator from openapi_core.validation.request import V31RequestValidator +from openapi_core.validation.request import V31WebhookRequestValidator from openapi_core.validation.request import openapi_request_body_validator from openapi_core.validation.request import ( openapi_request_parameters_validator, @@ -13,8 +15,10 @@ from openapi_core.validation.request import openapi_v30_request_validator from openapi_core.validation.request import openapi_v31_request_validator from openapi_core.validation.response import V3ResponseValidator +from openapi_core.validation.response import V3WebhookResponseValidator from openapi_core.validation.response import V30ResponseValidator from openapi_core.validation.response import V31ResponseValidator +from openapi_core.validation.response import V31WebhookResponseValidator from openapi_core.validation.response import openapi_response_data_validator from openapi_core.validation.response import openapi_response_headers_validator from openapi_core.validation.response import openapi_response_validator @@ -36,10 +40,14 @@ "validate_response", "V30RequestValidator", "V31RequestValidator", - "V3RequestValidator", "V30ResponseValidator", "V31ResponseValidator", + "V31WebhookRequestValidator", + "V31WebhookResponseValidator", + "V3RequestValidator", "V3ResponseValidator", + "V3WebhookRequestValidator", + "V3WebhookResponseValidator", "openapi_v3_request_validator", "openapi_v30_request_validator", "openapi_v31_request_validator", diff --git a/openapi_core/contrib/requests/__init__.py b/openapi_core/contrib/requests/__init__.py index e8615820..d0327d7d 100644 --- a/openapi_core/contrib/requests/__init__.py +++ b/openapi_core/contrib/requests/__init__.py @@ -1,7 +1,11 @@ from openapi_core.contrib.requests.requests import RequestsOpenAPIRequest +from openapi_core.contrib.requests.requests import ( + RequestsOpenAPIWebhookRequest, +) from openapi_core.contrib.requests.responses import RequestsOpenAPIResponse __all__ = [ "RequestsOpenAPIRequest", "RequestsOpenAPIResponse", + "RequestsOpenAPIWebhookRequest", ] diff --git a/openapi_core/contrib/requests/requests.py b/openapi_core/contrib/requests/requests.py index 57a9eafd..f666c939 100644 --- a/openapi_core/contrib/requests/requests.py +++ b/openapi_core/contrib/requests/requests.py @@ -16,7 +16,7 @@ class RequestsOpenAPIRequest: """ - Converts a requests request to an OpenAPI one + Converts a requests request to an OpenAPI request Internally converts to a `PreparedRequest` first to parse the exact payload being sent @@ -76,3 +76,16 @@ def mimetype(self) -> str: self.request.headers.get("Content-Type") or self.request.headers.get("Accept") ) + + +class RequestsOpenAPIWebhookRequest(RequestsOpenAPIRequest): + """ + Converts a requests request to an OpenAPI Webhook request + + Internally converts to a `PreparedRequest` first to parse the exact + payload being sent + """ + + def __init__(self, request: Union[Request, PreparedRequest], name: str): + super().__init__(request) + self.name = name diff --git a/openapi_core/security/providers.py b/openapi_core/security/providers.py index 8ce79f7a..27937b03 100644 --- a/openapi_core/security/providers.py +++ b/openapi_core/security/providers.py @@ -3,37 +3,37 @@ from openapi_core.security.exceptions import SecurityError from openapi_core.spec import Spec -from openapi_core.validation.request.protocols import Request +from openapi_core.validation.request.datatypes import RequestParameters class BaseProvider: def __init__(self, scheme: Spec): self.scheme = scheme - def __call__(self, request: Request) -> Any: + def __call__(self, parameters: RequestParameters) -> Any: raise NotImplementedError class UnsupportedProvider(BaseProvider): - def __call__(self, request: Request) -> Any: + def __call__(self, parameters: RequestParameters) -> Any: warnings.warn("Unsupported scheme type") class ApiKeyProvider(BaseProvider): - def __call__(self, request: Request) -> Any: + def __call__(self, parameters: RequestParameters) -> Any: name = self.scheme["name"] location = self.scheme["in"] - source = getattr(request.parameters, location) + source = getattr(parameters, location) if name not in source: raise SecurityError("Missing api key parameter.") return source[name] class HttpProvider(BaseProvider): - def __call__(self, request: Request) -> Any: - if "Authorization" not in request.parameters.header: + def __call__(self, parameters: RequestParameters) -> Any: + if "Authorization" not in parameters.header: raise SecurityError("Missing authorization header.") - auth_header = request.parameters.header["Authorization"] + auth_header = parameters.header["Authorization"] try: auth_type, encoded_credentials = auth_header.split(" ", 1) except ValueError: diff --git a/openapi_core/templating/paths/datatypes.py b/openapi_core/templating/paths/datatypes.py index 31d4a4e4..56093afe 100644 --- a/openapi_core/templating/paths/datatypes.py +++ b/openapi_core/templating/paths/datatypes.py @@ -2,10 +2,10 @@ from collections import namedtuple Path = namedtuple("Path", ["path", "path_result"]) -OperationPath = namedtuple( - "OperationPath", ["path", "operation", "path_result"] +PathOperation = namedtuple( + "PathOperation", ["path", "operation", "path_result"] ) -ServerOperationPath = namedtuple( - "ServerOperationPath", +PathOperationServer = namedtuple( + "PathOperationServer", ["path", "operation", "server", "path_result", "server_result"], ) diff --git a/openapi_core/templating/paths/finders.py b/openapi_core/templating/paths/finders.py index 0eb37430..f4c9cb04 100644 --- a/openapi_core/templating/paths/finders.py +++ b/openapi_core/templating/paths/finders.py @@ -10,9 +10,9 @@ from openapi_core.schema.servers import is_absolute from openapi_core.spec import Spec from openapi_core.templating.datatypes import TemplateResult -from openapi_core.templating.paths.datatypes import OperationPath from openapi_core.templating.paths.datatypes import Path -from openapi_core.templating.paths.datatypes import ServerOperationPath +from openapi_core.templating.paths.datatypes import PathOperation +from openapi_core.templating.paths.datatypes import PathOperationServer from openapi_core.templating.paths.exceptions import OperationNotFound from openapi_core.templating.paths.exceptions import PathNotFound from openapi_core.templating.paths.exceptions import ServerNotFound @@ -21,50 +21,69 @@ from openapi_core.templating.util import search -class PathFinder: +class BasePathFinder: def __init__(self, spec: Spec, base_url: Optional[str] = None): self.spec = spec self.base_url = base_url - def find( - self, - method: str, - full_url: str, - ) -> ServerOperationPath: - paths_iter = self._get_paths_iter(full_url) + def find(self, method: str, name: str) -> PathOperationServer: + paths_iter = self._get_paths_iter(name) paths_iter_peek = peekable(paths_iter) if not paths_iter_peek: - raise PathNotFound(full_url) + raise PathNotFound(name) - operations_iter = self._get_operations_iter(paths_iter_peek, method) + operations_iter = self._get_operations_iter(method, paths_iter_peek) operations_iter_peek = peekable(operations_iter) if not operations_iter_peek: - raise OperationNotFound(full_url, method) + raise OperationNotFound(name, method) servers_iter = self._get_servers_iter( + name, operations_iter_peek, - full_url, ) try: return next(servers_iter) except StopIteration: - raise ServerNotFound(full_url) + raise ServerNotFound(name) - def _get_paths_iter(self, full_url: str) -> Iterator[Path]: + def _get_paths_iter(self, name: str) -> Iterator[Path]: + raise NotImplementedError + + def _get_operations_iter( + self, method: str, paths_iter: Iterator[Path] + ) -> Iterator[PathOperation]: + for path, path_result in paths_iter: + if method not in path: + continue + operation = path / method + yield PathOperation(path, operation, path_result) + + def _get_servers_iter( + self, name: str, operations_iter: Iterator[PathOperation] + ) -> Iterator[PathOperationServer]: + raise NotImplementedError + + +class APICallPathFinder(BasePathFinder): + def __init__(self, spec: Spec, base_url: Optional[str] = None): + self.spec = spec + self.base_url = base_url + + def _get_paths_iter(self, name: str) -> Iterator[Path]: template_paths: List[Path] = [] paths = self.spec / "paths" for path_pattern, path in list(paths.items()): # simple path. # Return right away since it is always the most concrete - if full_url.endswith(path_pattern): + if name.endswith(path_pattern): path_result = TemplateResult(path_pattern, {}) yield Path(path, path_result) # template path else: - result = search(path_pattern, full_url) + result = search(path_pattern, name) if result: path_result = TemplateResult(path_pattern, result.named) template_paths.append(Path(path, path_result)) @@ -72,18 +91,9 @@ def _get_paths_iter(self, full_url: str) -> Iterator[Path]: # Fewer variables -> more concrete path yield from sorted(template_paths, key=template_path_len) - def _get_operations_iter( - self, paths_iter: Iterator[Path], request_method: str - ) -> Iterator[OperationPath]: - for path, path_result in paths_iter: - if request_method not in path: - continue - operation = path / request_method - yield OperationPath(path, operation, path_result) - def _get_servers_iter( - self, operations_iter: Iterator[OperationPath], full_url: str - ) -> Iterator[ServerOperationPath]: + self, name: str, operations_iter: Iterator[PathOperation] + ) -> Iterator[PathOperationServer]: for path, operation, path_result in operations_iter: servers = ( path.get("servers", None) @@ -91,9 +101,7 @@ def _get_servers_iter( or self.spec.get("servers", [{"url": "/"}]) ) for server in servers: - server_url_pattern = full_url.rsplit(path_result.resolved, 1)[ - 0 - ] + server_url_pattern = name.rsplit(path_result.resolved, 1)[0] server_url = server["url"] if not is_absolute(server_url): # relative to absolute url @@ -107,7 +115,7 @@ def _get_servers_iter( # simple path if server_url_pattern == server_url: server_result = TemplateResult(server["url"], {}) - yield ServerOperationPath( + yield PathOperationServer( path, operation, server, @@ -121,10 +129,33 @@ def _get_servers_iter( server_result = TemplateResult( server["url"], result.named ) - yield ServerOperationPath( + yield PathOperationServer( path, operation, server, path_result, server_result, ) + + +class WebhookPathFinder(BasePathFinder): + def _get_paths_iter(self, name: str) -> Iterator[Path]: + if "webhooks" not in self.spec: + raise PathNotFound("Webhooks not found") + webhooks = self.spec / "webhooks" + for webhook_name, path in list(webhooks.items()): + if name == webhook_name: + path_result = TemplateResult(webhook_name, {}) + yield Path(path, path_result) + + def _get_servers_iter( + self, name: str, operations_iter: Iterator[PathOperation] + ) -> Iterator[PathOperationServer]: + for path, operation, path_result in operations_iter: + yield PathOperationServer( + path, + operation, + None, + path_result, + {}, + ) diff --git a/openapi_core/validation/processors.py b/openapi_core/validation/processors.py index 3b21c71a..8f7eb3df 100644 --- a/openapi_core/validation/processors.py +++ b/openapi_core/validation/processors.py @@ -47,9 +47,9 @@ def __init__( if request_validator_cls is None or response_validator_cls is None: validators = get_validators(self.spec) if request_validator_cls is None: - request_validator_cls = validators.request + request_validator_cls = validators.request_cls if response_validator_cls is None: - response_validator_cls = validators.response + response_validator_cls = validators.response_cls self.request_validator = request_validator_cls(self.spec) self.response_validator = response_validator_cls(self.spec) diff --git a/openapi_core/validation/request/__init__.py b/openapi_core/validation/request/__init__.py index 9ff42510..71a6127f 100644 --- a/openapi_core/validation/request/__init__.py +++ b/openapi_core/validation/request/__init__.py @@ -33,6 +33,18 @@ V31RequestSecurityValidator, ) from openapi_core.validation.request.validators import V31RequestValidator +from openapi_core.validation.request.validators import ( + V31WebhookRequestBodyValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestSecurityValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestValidator, +) __all__ = [ "V30RequestBodyValidator", @@ -43,7 +55,12 @@ "V31RequestParametersValidator", "V31RequestSecurityValidator", "V31RequestValidator", + "V31WebhookRequestBodyValidator", + "V31WebhookRequestParametersValidator", + "V31WebhookRequestSecurityValidator", + "V31WebhookRequestValidator", "V3RequestValidator", + "V3WebhookRequestValidator", "openapi_v30_request_body_validator", "openapi_v30_request_parameters_validator", "openapi_v30_request_security_validator", @@ -64,6 +81,7 @@ # alias to the latest v3 version V3RequestValidator = V31RequestValidator +V3WebhookRequestValidator = V31WebhookRequestValidator # spec validators openapi_v30_request_body_validator = SpecRequestValidatorProxy( diff --git a/openapi_core/validation/request/exceptions.py b/openapi_core/validation/request/exceptions.py index b812936e..43911107 100644 --- a/openapi_core/validation/request/exceptions.py +++ b/openapi_core/validation/request/exceptions.py @@ -30,17 +30,11 @@ class MissingRequestBodyError(OpenAPIRequestBodyError): """Missing request body error""" -@dataclass class MissingRequestBody(MissingRequestBodyError): - request: Request - def __str__(self) -> str: return "Missing request body" -@dataclass class MissingRequiredRequestBody(MissingRequestBodyError): - request: Request - def __str__(self) -> str: return "Missing required request body" diff --git a/openapi_core/validation/request/protocols.py b/openapi_core/validation/request/protocols.py index a3506952..8bc2bed0 100644 --- a/openapi_core/validation/request/protocols.py +++ b/openapi_core/validation/request/protocols.py @@ -15,7 +15,25 @@ @runtime_checkable -class Request(Protocol): +class BaseRequest(Protocol): + + parameters: RequestParameters + + @property + def method(self) -> str: + ... + + @property + def body(self) -> Optional[str]: + ... + + @property + def mimetype(self) -> str: + ... + + +@runtime_checkable +class Request(BaseRequest, Protocol): """Request attributes protocol. Attributes: @@ -44,8 +62,6 @@ class Request(Protocol): the mimetype would be "text/html". """ - parameters: RequestParameters - @property def host_url(self) -> str: ... @@ -54,16 +70,30 @@ def host_url(self) -> str: def path(self) -> str: ... - @property - def method(self) -> str: - ... - @property - def body(self) -> Optional[str]: - ... +@runtime_checkable +class WebhookRequest(BaseRequest, Protocol): + """Webhook request attributes protocol. + + Attributes: + name + Webhook name + method + The request method, as lowercase string. + parameters + A RequestParameters object. Needs to supports path attribute setter + to write resolved path parameters. + body + The request body, as string. + mimetype + Like content type, but without parameters (eg, without charset, + type etc.) and always lowercase. + For example if the content type is "text/HTML; charset=utf-8" + the mimetype would be "text/html". + """ @property - def mimetype(self) -> str: + def name(self) -> str: ... @@ -95,3 +125,15 @@ def validate( request: Request, ) -> RequestValidationResult: ... + + +@runtime_checkable +class WebhookRequestValidator(Protocol): + def __init__(self, spec: Spec, base_url: Optional[str] = None): + ... + + def validate( + self, + request: WebhookRequest, + ) -> RequestValidationResult: + ... diff --git a/openapi_core/validation/request/proxies.py b/openapi_core/validation/request/proxies.py index c667af75..1dd3feba 100644 --- a/openapi_core/validation/request/proxies.py +++ b/openapi_core/validation/request/proxies.py @@ -11,13 +11,15 @@ from openapi_core.validation.exceptions import ValidatorDetectError from openapi_core.validation.request.datatypes import RequestValidationResult from openapi_core.validation.request.protocols import Request -from openapi_core.validation.request.validators import BaseRequestValidator +from openapi_core.validation.request.validators import ( + BaseAPICallRequestValidator, +) class SpecRequestValidatorProxy: def __init__( self, - validator_cls: Type[BaseRequestValidator], + validator_cls: Type[BaseAPICallRequestValidator], **validator_kwargs: Any, ): self.validator_cls = validator_cls diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index 29a4ef53..48158586 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -4,6 +4,7 @@ from typing import Dict from typing import Iterator from typing import Optional +from urllib.parse import urljoin from openapi_core.casting.schemas import schema_casters_factory from openapi_core.casting.schemas.exceptions import CastError @@ -27,7 +28,10 @@ from openapi_core.security.factories import SecurityProviderFactory from openapi_core.spec.paths import Spec from openapi_core.templating.media_types.exceptions import MediaTypeFinderError +from openapi_core.templating.paths.datatypes import PathOperationServer from openapi_core.templating.paths.exceptions import PathError +from openapi_core.templating.paths.finders import APICallPathFinder +from openapi_core.templating.paths.finders import WebhookPathFinder from openapi_core.unmarshalling.schemas import ( oas30_request_schema_unmarshallers_factory, ) @@ -44,14 +48,19 @@ from openapi_core.validation.exceptions import MissingParameter from openapi_core.validation.exceptions import MissingRequiredParameter from openapi_core.validation.request.datatypes import Parameters +from openapi_core.validation.request.datatypes import RequestParameters from openapi_core.validation.request.datatypes import RequestValidationResult from openapi_core.validation.request.exceptions import MissingRequestBody from openapi_core.validation.request.exceptions import ( MissingRequiredRequestBody, ) from openapi_core.validation.request.exceptions import ParametersError +from openapi_core.validation.request.protocols import BaseRequest from openapi_core.validation.request.protocols import Request +from openapi_core.validation.request.protocols import WebhookRequest +from openapi_core.validation.validators import BaseAPICallValidator from openapi_core.validation.validators import BaseValidator +from openapi_core.validation.validators import BaseWebhookValidator class BaseRequestValidator(BaseValidator): @@ -77,22 +86,115 @@ def __init__( ) self.security_provider_factory = security_provider_factory - def iter_errors(self, request: Request) -> Iterator[Exception]: - result = self.validate(request) - yield from result.errors + def _validate( + self, request: BaseRequest, operation: Spec, path: Spec + ) -> RequestValidationResult: + try: + security = self._get_security(request.parameters, operation) + except InvalidSecurity as exc: + return RequestValidationResult(errors=[exc]) - def validate(self, request: Request) -> RequestValidationResult: - raise NotImplementedError + try: + params = self._get_parameters(request.parameters, operation, path) + except ParametersError as exc: + params = exc.parameters + params_errors = exc.context + else: + params_errors = [] + + try: + body = self._get_body(request.body, request.mimetype, operation) + except ( + MissingRequiredRequestBody, + MediaTypeFinderError, + DeserializeError, + CastError, + ValidateError, + UnmarshalError, + ) as exc: + body = None + body_errors = [exc] + except MissingRequestBody: + body = None + body_errors = [] + else: + body_errors = [] + + errors = list(chainiters(params_errors, body_errors)) + return RequestValidationResult( + errors=errors, + body=body, + parameters=params, + security=security, + ) + + def _validate_body( + self, request: BaseRequest, operation: Spec + ) -> RequestValidationResult: + try: + body = self._get_body(request.body, request.mimetype, operation) + except ( + MissingRequiredRequestBody, + MediaTypeFinderError, + DeserializeError, + CastError, + ValidateError, + UnmarshalError, + ) as exc: + body = None + errors = [exc] + except MissingRequestBody: + body = None + errors = [] + else: + errors = [] + + return RequestValidationResult( + errors=errors, + body=body, + ) + + def _validate_parameters( + self, request: BaseRequest, operation: Spec, path: Spec + ) -> RequestValidationResult: + try: + params = self._get_parameters(request.parameters, path, operation) + except ParametersError as exc: + params = exc.parameters + params_errors = exc.context + else: + params_errors = [] + + return RequestValidationResult( + errors=params_errors, + parameters=params, + ) + + def _validate_security( + self, request: BaseRequest, operation: Spec + ) -> RequestValidationResult: + try: + security = self._get_security(request.parameters, operation) + except InvalidSecurity as exc: + return RequestValidationResult(errors=[exc]) + + return RequestValidationResult( + errors=[], + security=security, + ) def _get_parameters( - self, request: Request, path: Spec, operation: Spec + self, + parameters: RequestParameters, + operation: Spec, + path: Spec, ) -> Parameters: operation_params = operation.get("parameters", []) path_params = path.get("parameters", []) errors = [] seen = set() - parameters = Parameters() + validated = Parameters() params_iter = chainiters(operation_params, path_params) for param in params_iter: param_name = param["name"] @@ -103,7 +205,7 @@ def _get_parameters( continue seen.add((param_name, param_location)) try: - value = self._get_parameter(param, request) + value = self._get_parameter(parameters, param) except MissingParameter: continue except ( @@ -116,15 +218,17 @@ def _get_parameters( errors.append(exc) continue else: - location = getattr(parameters, param_location) + location = getattr(validated, param_location) location[param_name] = value if errors: - raise ParametersError(errors=errors, parameters=parameters) + raise ParametersError(errors=errors, parameters=validated) - return parameters + return validated - def _get_parameter(self, param: Spec, request: Request) -> Any: + def _get_parameter( + self, parameters: RequestParameters, param: Spec + ) -> Any: name = param["name"] deprecated = param.getkey("deprecated", False) if deprecated: @@ -134,7 +238,7 @@ def _get_parameter(self, param: Spec, request: Request) -> Any: ) param_location = param["in"] - location = request.parameters[param_location] + location = parameters[param_location] try: return self._get_param_or_header_value(param, location) except KeyError: @@ -144,7 +248,7 @@ def _get_parameter(self, param: Spec, request: Request) -> Any: raise MissingParameter(name) def _get_security( - self, request: Request, operation: Spec + self, parameters: RequestParameters, operation: Spec ) -> Optional[Dict[str, str]]: security = None if "security" in self.spec: @@ -158,7 +262,9 @@ def _get_security( for security_requirement in security: try: return { - scheme_name: self._get_security_value(scheme_name, request) + scheme_name: self._get_security_value( + parameters, scheme_name + ) for scheme_name in list(security_requirement.keys()) } except SecurityError: @@ -166,23 +272,27 @@ def _get_security( raise InvalidSecurity - def _get_security_value(self, scheme_name: str, request: Request) -> Any: + def _get_security_value( + self, parameters: RequestParameters, scheme_name: str + ) -> Any: security_schemes = self.spec / "components#securitySchemes" if scheme_name not in security_schemes: return scheme = security_schemes[scheme_name] security_provider = self.security_provider_factory.create(scheme) - return security_provider(request) + return security_provider(parameters) - def _get_body(self, request: Request, operation: Spec) -> Any: + def _get_body( + self, body: Optional[str], mimetype: str, operation: Spec + ) -> Any: if "requestBody" not in operation: return None request_body = operation / "requestBody" - raw_body = self._get_body_value(request_body, request) + raw_body = self._get_body_value(body, request_body) media_type, mimetype = self._get_media_type( - request_body / "content", request.mimetype + request_body / "content", mimetype ) deserialised = self._deserialise_data(mimetype, raw_body) casted = self._cast(media_type, deserialised) @@ -191,142 +301,131 @@ def _get_body(self, request: Request, operation: Spec) -> Any: return casted schema = media_type / "schema" - body = self._unmarshal(schema, casted) + unmarshalled = self._unmarshal(schema, casted) + return unmarshalled + def _get_body_value(self, body: Optional[str], request_body: Spec) -> Any: + if not body: + if request_body.getkey("required", False): + raise MissingRequiredRequestBody + raise MissingRequestBody return body - def _get_body_value(self, request_body: Spec, request: Request) -> Any: - if not request.body: - if request_body.getkey("required", False): - raise MissingRequiredRequestBody(request) - raise MissingRequestBody(request) - return request.body +class BaseAPICallRequestValidator(BaseRequestValidator, BaseAPICallValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: + result = self.validate(request) + yield from result.errors -class RequestParametersValidator(BaseRequestValidator): def validate(self, request: Request) -> RequestValidationResult: - try: - path, operation, _, path_result, _ = self._find_path(request) - except PathError as exc: - return RequestValidationResult(errors=[exc]) + raise NotImplementedError - request.parameters.path = ( - request.parameters.path or path_result.variables - ) - try: - params = self._get_parameters(request, path, operation) - except ParametersError as exc: - params = exc.parameters - params_errors = exc.context - else: - params_errors = [] +class BaseWebhookRequestValidator(BaseRequestValidator, BaseWebhookValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + result = self.validate(request) + yield from result.errors - return RequestValidationResult( - errors=params_errors, - parameters=params, - ) + def validate(self, request: WebhookRequest) -> RequestValidationResult: + raise NotImplementedError -class RequestBodyValidator(BaseRequestValidator): +class RequestBodyValidator(BaseAPICallRequestValidator): def validate(self, request: Request) -> RequestValidationResult: try: _, operation, _, _, _ = self._find_path(request) except PathError as exc: return RequestValidationResult(errors=[exc]) + return self._validate_body(request, operation) + + +class RequestParametersValidator(BaseAPICallRequestValidator): + def validate(self, request: Request) -> RequestValidationResult: try: - body = self._get_body(request, operation) - except ( - MissingRequiredRequestBody, - MediaTypeFinderError, - DeserializeError, - CastError, - ValidateError, - UnmarshalError, - ) as exc: - body = None - errors = [exc] - except MissingRequestBody: - body = None - errors = [] - else: - errors = [] + path, operation, _, path_result, _ = self._find_path(request) + except PathError as exc: + return RequestValidationResult(errors=[exc]) - return RequestValidationResult( - errors=errors, - body=body, + request.parameters.path = ( + request.parameters.path or path_result.variables ) + return self._validate_parameters(request, operation, path) + -class RequestSecurityValidator(BaseRequestValidator): +class RequestSecurityValidator(BaseAPICallRequestValidator): def validate(self, request: Request) -> RequestValidationResult: try: _, operation, _, _, _ = self._find_path(request) except PathError as exc: return RequestValidationResult(errors=[exc]) + return self._validate_security(request, operation) + + +class RequestValidator(BaseAPICallRequestValidator): + def validate(self, request: Request) -> RequestValidationResult: try: - security = self._get_security(request, operation) - except InvalidSecurity as exc: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: return RequestValidationResult(errors=[exc]) - return RequestValidationResult( - errors=[], - security=security, + request.parameters.path = ( + request.parameters.path or path_result.variables ) + return self._validate(request, operation, path) -class RequestValidator(BaseRequestValidator): - def validate(self, request: Request) -> RequestValidationResult: + +class WebhookRequestValidator(BaseWebhookRequestValidator): + def validate(self, request: WebhookRequest) -> RequestValidationResult: try: path, operation, _, path_result, _ = self._find_path(request) # don't process if operation errors except PathError as exc: return RequestValidationResult(errors=[exc]) + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._validate(request, operation, path) + + +class WebhookRequestBodyValidator(BaseWebhookRequestValidator): + def validate(self, request: WebhookRequest) -> RequestValidationResult: try: - security = self._get_security(request, operation) - except InvalidSecurity as exc: + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + return RequestValidationResult(errors=[exc]) + + return self._validate_body(request, operation) + + +class WebhookRequestParametersValidator(BaseWebhookRequestValidator): + def validate(self, request: WebhookRequest) -> RequestValidationResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + except PathError as exc: return RequestValidationResult(errors=[exc]) request.parameters.path = ( request.parameters.path or path_result.variables ) - try: - params = self._get_parameters(request, path, operation) - except ParametersError as exc: - params = exc.parameters - params_errors = exc.context - else: - params_errors = [] + return self._validate_parameters(request, operation, path) + +class WebhookRequestSecurityValidator(BaseWebhookRequestValidator): + def validate(self, request: WebhookRequest) -> RequestValidationResult: try: - body = self._get_body(request, operation) - except ( - MissingRequiredRequestBody, - MediaTypeFinderError, - DeserializeError, - CastError, - ValidateError, - UnmarshalError, - ) as exc: - body = None - body_errors = [exc] - except MissingRequestBody: - body = None - body_errors = [] - else: - body_errors = [] + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + return RequestValidationResult(errors=[exc]) - errors = list(chainiters(params_errors, body_errors)) - return RequestValidationResult( - errors=errors, - body=body, - parameters=params, - security=security, - ) + return self._validate_security(request, operation) class V30RequestBodyValidator(RequestBodyValidator): @@ -359,3 +458,24 @@ class V31RequestSecurityValidator(RequestSecurityValidator): class V31RequestValidator(RequestValidator): schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + path_finder_cls = WebhookPathFinder + + +class V31WebhookRequestBodyValidator(WebhookRequestBodyValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + path_finder_cls = WebhookPathFinder + + +class V31WebhookRequestParametersValidator(WebhookRequestParametersValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + path_finder_cls = WebhookPathFinder + + +class V31WebhookRequestSecurityValidator(WebhookRequestSecurityValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + path_finder_cls = WebhookPathFinder + + +class V31WebhookRequestValidator(WebhookRequestValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + path_finder_cls = WebhookPathFinder diff --git a/openapi_core/validation/response/__init__.py b/openapi_core/validation/response/__init__.py index 09ec44f4..08a2de89 100644 --- a/openapi_core/validation/response/__init__.py +++ b/openapi_core/validation/response/__init__.py @@ -30,6 +30,15 @@ V31ResponseHeadersValidator, ) from openapi_core.validation.response.validators import V31ResponseValidator +from openapi_core.validation.response.validators import ( + V31WebhookResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseHeadersValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseValidator, +) __all__ = [ "V30ResponseDataValidator", @@ -38,7 +47,11 @@ "V31ResponseDataValidator", "V31ResponseHeadersValidator", "V31ResponseValidator", + "V31WebhookResponseDataValidator", + "V31WebhookResponseHeadersValidator", + "V31WebhookResponseValidator", "V3ResponseValidator", + "V3WebhookResponseValidator", "openapi_v30_response_data_validator", "openapi_v30_response_headers_validator", "openapi_v30_response_validator", @@ -55,6 +68,7 @@ # alias to the latest v3 version V3ResponseValidator = V31ResponseValidator +V3WebhookResponseValidator = V31WebhookResponseValidator # spec validators openapi_v30_response_data_validator = SpecResponseValidatorProxy( diff --git a/openapi_core/validation/response/exceptions.py b/openapi_core/validation/response/exceptions.py index 277556c6..54711cc2 100644 --- a/openapi_core/validation/response/exceptions.py +++ b/openapi_core/validation/response/exceptions.py @@ -17,9 +17,6 @@ class OpenAPIResponseError(OpenAPIError): pass -@dataclass class MissingResponseContent(OpenAPIResponseError): - response: Response - def __str__(self) -> str: return "Missing response content" diff --git a/openapi_core/validation/response/protocols.py b/openapi_core/validation/response/protocols.py index dc06ae6b..dfcb9a87 100644 --- a/openapi_core/validation/response/protocols.py +++ b/openapi_core/validation/response/protocols.py @@ -13,6 +13,7 @@ from openapi_core.spec import Spec from openapi_core.validation.request.protocols import Request +from openapi_core.validation.request.protocols import WebhookRequest from openapi_core.validation.response.datatypes import ResponseValidationResult @@ -59,3 +60,16 @@ def validate( response: Response, ) -> ResponseValidationResult: ... + + +@runtime_checkable +class WebhookResponseValidator(Protocol): + def __init__(self, spec: Spec, base_url: Optional[str] = None): + ... + + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseValidationResult: + ... diff --git a/openapi_core/validation/response/proxies.py b/openapi_core/validation/response/proxies.py index 16cdc276..fe399cc6 100644 --- a/openapi_core/validation/response/proxies.py +++ b/openapi_core/validation/response/proxies.py @@ -12,13 +12,15 @@ from openapi_core.validation.request.protocols import Request from openapi_core.validation.response.datatypes import ResponseValidationResult from openapi_core.validation.response.protocols import Response -from openapi_core.validation.response.validators import BaseResponseValidator +from openapi_core.validation.response.validators import ( + BaseAPICallResponseValidator, +) class SpecResponseValidatorProxy: def __init__( self, - validator_cls: Type[BaseResponseValidator], + validator_cls: Type[BaseAPICallResponseValidator], **validator_kwargs: Any, ): self.validator_cls = validator_cls diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index 6a32db57..7c369417 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -4,14 +4,19 @@ from typing import Dict from typing import Iterator from typing import List +from typing import Mapping from typing import Optional +from urllib.parse import urljoin from openapi_core.casting.schemas.exceptions import CastError from openapi_core.deserializing.exceptions import DeserializeError from openapi_core.exceptions import OpenAPIError from openapi_core.spec import Spec from openapi_core.templating.media_types.exceptions import MediaTypeFinderError +from openapi_core.templating.paths.datatypes import PathOperationServer from openapi_core.templating.paths.exceptions import PathError +from openapi_core.templating.paths.finders import APICallPathFinder +from openapi_core.templating.paths.finders import WebhookPathFinder from openapi_core.templating.responses.exceptions import ResponseFinderError from openapi_core.unmarshalling.schemas import ( oas30_response_schema_unmarshallers_factory, @@ -25,53 +30,138 @@ from openapi_core.validation.exceptions import MissingHeader from openapi_core.validation.exceptions import MissingRequiredHeader from openapi_core.validation.request.protocols import Request +from openapi_core.validation.request.protocols import WebhookRequest from openapi_core.validation.response.datatypes import ResponseValidationResult from openapi_core.validation.response.exceptions import HeadersError from openapi_core.validation.response.exceptions import MissingResponseContent from openapi_core.validation.response.protocols import Response +from openapi_core.validation.validators import BaseAPICallValidator from openapi_core.validation.validators import BaseValidator +from openapi_core.validation.validators import BaseWebhookValidator class BaseResponseValidator(BaseValidator): - def iter_errors( + def _validate( self, - request: Request, - response: Response, - ) -> Iterator[Exception]: - result = self.validate(request, response) - yield from result.errors + status_code: int, + data: str, + headers: Mapping[str, Any], + mimetype: str, + operation: Spec, + ) -> ResponseValidationResult: + try: + operation_response = self._get_operation_response( + status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseValidationResult(errors=[exc]) - def validate( - self, - request: Request, - response: Response, + try: + validated_data = self._get_data(data, mimetype, operation_response) + except ( + MediaTypeFinderError, + MissingResponseContent, + DeserializeError, + CastError, + ValidateError, + UnmarshalError, + ) as exc: + validated_data = None + data_errors = [exc] + else: + data_errors = [] + + try: + validated_headers = self._get_headers(headers, operation_response) + except HeadersError as exc: + validated_headers = exc.headers + headers_errors = exc.context + else: + headers_errors = [] + + errors = list(chainiters(data_errors, headers_errors)) + return ResponseValidationResult( + errors=errors, + data=validated_data, + headers=validated_headers, + ) + + def _validate_data( + self, status_code: int, data: str, mimetype: str, operation: Spec ) -> ResponseValidationResult: - raise NotImplementedError + try: + operation_response = self._get_operation_response( + status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseValidationResult(errors=[exc]) - def _find_operation_response( - self, - request: Request, - response: Response, - ) -> Spec: - _, operation, _, _, _ = self._find_path(request) - return self._get_operation_response(operation, response) + try: + validated = self._get_data(data, mimetype, operation_response) + except ( + MediaTypeFinderError, + MissingResponseContent, + DeserializeError, + CastError, + ValidateError, + UnmarshalError, + ) as exc: + validated = None + data_errors = [exc] + else: + data_errors = [] + + return ResponseValidationResult( + errors=data_errors, + data=validated, + ) + + def _validate_headers( + self, status_code: int, headers: Mapping[str, Any], operation: Spec + ) -> ResponseValidationResult: + try: + operation_response = self._get_operation_response( + status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseValidationResult(errors=[exc]) + + try: + validated = self._get_headers(headers, operation_response) + except HeadersError as exc: + validated = exc.headers + headers_errors = exc.context + else: + headers_errors = [] + + return ResponseValidationResult( + errors=headers_errors, + headers=validated, + ) def _get_operation_response( - self, operation: Spec, response: Response + self, + status_code: int, + operation: Spec, ) -> Spec: from openapi_core.templating.responses.finders import ResponseFinder finder = ResponseFinder(operation / "responses") - return finder.find(str(response.status_code)) + return finder.find(str(status_code)) - def _get_data(self, response: Response, operation_response: Spec) -> Any: + def _get_data( + self, data: str, mimetype: str, operation_response: Spec + ) -> Any: if "content" not in operation_response: return None media_type, mimetype = self._get_media_type( - operation_response / "content", response.mimetype + operation_response / "content", mimetype ) - raw_data = self._get_data_value(response) + raw_data = self._get_data_value(data) deserialised = self._deserialise_data(mimetype, raw_data) casted = self._cast(media_type, deserialised) @@ -83,28 +173,28 @@ def _get_data(self, response: Response, operation_response: Spec) -> Any: return data - def _get_data_value(self, response: Response) -> Any: - if not response.data: - raise MissingResponseContent(response) + def _get_data_value(self, data: str) -> Any: + if not data: + raise MissingResponseContent - return response.data + return data def _get_headers( - self, response: Response, operation_response: Spec + self, headers: Mapping[str, Any], operation_response: Spec ) -> Dict[str, Any]: if "headers" not in operation_response: return {} - headers = operation_response / "headers" + response_headers = operation_response / "headers" errors: List[OpenAPIError] = [] validated: Dict[str, Any] = {} - for name, header in list(headers.items()): + for name, header in list(response_headers.items()): # ignore Content-Type header if name.lower() == "content-type": continue try: - value = self._get_header(name, header, response) + value = self._get_header(headers, name, header) except MissingHeader: continue except ( @@ -124,7 +214,9 @@ def _get_headers( return validated - def _get_header(self, name: str, header: Spec, response: Response) -> Any: + def _get_header( + self, headers: Mapping[str, Any], name: str, header: Spec + ) -> Any: deprecated = header.getkey("deprecated", False) if deprecated: warnings.warn( @@ -133,9 +225,7 @@ def _get_header(self, name: str, header: Spec, response: Response) -> Any: ) try: - return self._get_param_or_header_value( - header, response.headers, name=name - ) + return self._get_param_or_header_value(header, headers, name=name) except KeyError: required = header.getkey("required", False) if required: @@ -143,114 +233,151 @@ def _get_header(self, name: str, header: Spec, response: Response) -> Any: raise MissingHeader(name) -class ResponseDataValidator(BaseResponseValidator): +class BaseAPICallResponseValidator( + BaseResponseValidator, BaseAPICallValidator +): + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: + result = self.validate(request, response) + yield from result.errors + + def validate( + self, + request: Request, + response: Response, + ) -> ResponseValidationResult: + raise NotImplementedError + + +class BaseWebhookResponseValidator( + BaseResponseValidator, BaseWebhookValidator +): + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: + result = self.validate(request, response) + yield from result.errors + + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseValidationResult: + raise NotImplementedError + + +class ResponseDataValidator(BaseAPICallResponseValidator): def validate( self, request: Request, response: Response, ) -> ResponseValidationResult: try: - operation_response = self._find_operation_response( - request, - response, - ) + _, operation, _, _, _ = self._find_path(request) # don't process if operation errors - except (PathError, ResponseFinderError) as exc: + except PathError as exc: return ResponseValidationResult(errors=[exc]) + return self._validate_data( + response.status_code, response.data, response.mimetype, operation + ) + + +class ResponseHeadersValidator(BaseAPICallResponseValidator): + def validate( + self, + request: Request, + response: Response, + ) -> ResponseValidationResult: try: - data = self._get_data(response, operation_response) - except ( - MediaTypeFinderError, - MissingResponseContent, - DeserializeError, - CastError, - ValidateError, - UnmarshalError, - ) as exc: - data = None - data_errors = [exc] - else: - data_errors = [] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseValidationResult(errors=[exc]) - return ResponseValidationResult( - errors=data_errors, - data=data, + return self._validate_headers( + response.status_code, response.headers, operation ) -class ResponseHeadersValidator(BaseResponseValidator): +class ResponseValidator(BaseAPICallResponseValidator): def validate( self, request: Request, response: Response, ) -> ResponseValidationResult: try: - operation_response = self._find_operation_response( - request, - response, - ) + _, operation, _, _, _ = self._find_path(request) # don't process if operation errors - except (PathError, ResponseFinderError) as exc: + except PathError as exc: return ResponseValidationResult(errors=[exc]) + return self._validate( + response.status_code, + response.data, + response.headers, + response.mimetype, + operation, + ) + + +class WebhookResponseDataValidator(BaseWebhookResponseValidator): + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseValidationResult: try: - headers = self._get_headers(response, operation_response) - except HeadersError as exc: - headers = exc.headers - headers_errors = exc.context - else: - headers_errors = [] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseValidationResult(errors=[exc]) - return ResponseValidationResult( - errors=headers_errors, - headers=headers, + return self._validate_data( + response.status_code, response.data, response.mimetype, operation ) -class ResponseValidator(BaseResponseValidator): +class WebhookResponseHeadersValidator(BaseWebhookResponseValidator): def validate( self, - request: Request, + request: WebhookRequest, response: Response, ) -> ResponseValidationResult: try: - operation_response = self._find_operation_response( - request, - response, - ) + _, operation, _, _, _ = self._find_path(request) # don't process if operation errors - except (PathError, ResponseFinderError) as exc: + except PathError as exc: return ResponseValidationResult(errors=[exc]) - try: - data = self._get_data(response, operation_response) - except ( - MediaTypeFinderError, - MissingResponseContent, - DeserializeError, - CastError, - ValidateError, - UnmarshalError, - ) as exc: - data = None - data_errors = [exc] - else: - data_errors = [] + return self._validate_headers( + response.status_code, response.headers, operation + ) + +class WebhookResponseValidator(BaseWebhookResponseValidator): + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseValidationResult: try: - headers = self._get_headers(response, operation_response) - except HeadersError as exc: - headers = exc.headers - headers_errors = exc.context - else: - headers_errors = [] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseValidationResult(errors=[exc]) - errors = list(chainiters(data_errors, headers_errors)) - return ResponseValidationResult( - errors=errors, - data=data, - headers=headers, + return self._validate( + response.status_code, + response.data, + response.headers, + response.mimetype, + operation, ) @@ -276,3 +403,15 @@ class V31ResponseHeadersValidator(ResponseHeadersValidator): class V31ResponseValidator(ResponseValidator): schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseDataValidator(WebhookResponseDataValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseHeadersValidator(WebhookResponseHeadersValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseValidator(WebhookResponseValidator): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory diff --git a/openapi_core/validation/shortcuts.py b/openapi_core/validation/shortcuts.py index bf94c3c9..6a07bd3a 100644 --- a/openapi_core/validation/shortcuts.py +++ b/openapi_core/validation/shortcuts.py @@ -5,22 +5,40 @@ from typing import NamedTuple from typing import Optional from typing import Type +from typing import Union from openapi_core.spec import Spec from openapi_core.validation.exceptions import ValidatorDetectError from openapi_core.validation.request import V30RequestValidator from openapi_core.validation.request import V31RequestValidator +from openapi_core.validation.request import V31WebhookRequestValidator from openapi_core.validation.request.datatypes import RequestValidationResult from openapi_core.validation.request.protocols import Request from openapi_core.validation.request.protocols import RequestValidator +from openapi_core.validation.request.protocols import WebhookRequest +from openapi_core.validation.request.protocols import WebhookRequestValidator from openapi_core.validation.request.proxies import SpecRequestValidatorProxy from openapi_core.validation.response import V30ResponseValidator from openapi_core.validation.response import V31ResponseValidator +from openapi_core.validation.response import V31WebhookResponseValidator from openapi_core.validation.response.datatypes import ResponseValidationResult from openapi_core.validation.response.protocols import Response from openapi_core.validation.response.protocols import ResponseValidator +from openapi_core.validation.response.protocols import WebhookResponseValidator from openapi_core.validation.response.proxies import SpecResponseValidatorProxy +AnyRequest = Union[Request, WebhookRequest] +RequestValidatorType = Type[RequestValidator] +ResponseValidatorType = Type[ResponseValidator] +WebhookRequestValidatorType = Type[WebhookRequestValidator] +WebhookResponseValidatorType = Type[WebhookResponseValidator] +AnyRequestValidatorType = Union[ + RequestValidatorType, WebhookRequestValidatorType +] +AnyResponseValidatorType = Union[ + ResponseValidatorType, WebhookResponseValidatorType +] + class SpecVersion(NamedTuple): name: str @@ -28,16 +46,24 @@ class SpecVersion(NamedTuple): class SpecValidators(NamedTuple): - request: Type[RequestValidator] - response: Type[ResponseValidator] + request_cls: Type[RequestValidator] + response_cls: Type[ResponseValidator] + webhook_request_cls: Optional[Type[WebhookRequestValidator]] + webhook_response_cls: Optional[Type[WebhookResponseValidator]] SPECS: Dict[SpecVersion, SpecValidators] = { SpecVersion("openapi", "3.0"): SpecValidators( - V30RequestValidator, V30ResponseValidator + V30RequestValidator, + V30ResponseValidator, + None, + None, ), SpecVersion("openapi", "3.1"): SpecValidators( - V31RequestValidator, V31ResponseValidator + V31RequestValidator, + V31ResponseValidator, + V31WebhookRequestValidator, + V31WebhookResponseValidator, ), } @@ -50,14 +76,16 @@ def get_validators(spec: Spec) -> SpecValidators: def validate_request( - request: Request, + request: AnyRequest, spec: Spec, base_url: Optional[str] = None, validator: Optional[SpecRequestValidatorProxy] = None, - cls: Optional[Type[RequestValidator]] = None, + cls: Optional[AnyRequestValidatorType] = None, **validator_kwargs: Any, ) -> RequestValidationResult: - if validator is not None: + if not isinstance(request, (Request, WebhookRequest)): + raise TypeError("'request' is not (Webhook)Request") + if validator is not None and isinstance(request, Request): warnings.warn( "validator parameter is deprecated. Use cls instead.", DeprecationWarning, @@ -66,7 +94,18 @@ def validate_request( else: if cls is None: validators = get_validators(spec) - cls = getattr(validators, "request") + if isinstance(request, WebhookRequest): + cls = validators.webhook_request_cls + else: + cls = validators.request_cls + if cls is None: + raise ValidatorDetectError("Validator not found") + assert ( + isinstance(cls, RequestValidator) and isinstance(request, Request) + ) or ( + isinstance(cls, WebhookRequestValidator) + and isinstance(request, WebhookRequest) + ) v = cls(spec, base_url=base_url, **validator_kwargs) result = v.validate(request) result.raise_for_errors() @@ -74,15 +113,19 @@ def validate_request( def validate_response( - request: Request, + request: AnyRequest, response: Response, spec: Spec, base_url: Optional[str] = None, validator: Optional[SpecResponseValidatorProxy] = None, - cls: Optional[Type[ResponseValidator]] = None, + cls: Optional[AnyResponseValidatorType] = None, **validator_kwargs: Any, ) -> ResponseValidationResult: - if validator is not None: + if not isinstance(request, (Request, WebhookRequest)): + raise TypeError("'request' is not (Webhook)Request") + if not isinstance(response, Response): + raise TypeError("'response' is not Response") + if validator is not None and isinstance(request, Request): warnings.warn( "validator parameter is deprecated. Use cls instead.", DeprecationWarning, @@ -91,7 +134,18 @@ def validate_response( else: if cls is None: validators = get_validators(spec) - cls = getattr(validators, "response") + if isinstance(request, WebhookRequest): + cls = validators.webhook_response_cls + else: + cls = validators.response_cls + if cls is None: + raise ValidatorDetectError("Validator not found") + assert ( + isinstance(cls, ResponseValidator) and isinstance(request, Request) + ) or ( + isinstance(cls, WebhookResponseValidator) + and isinstance(request, WebhookRequest) + ) v = cls(spec, base_url=base_url, **validator_kwargs) result = v.validate(request, response) result.raise_for_errors() diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 45758489..b310249a 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -1,11 +1,17 @@ """OpenAPI core validation validators module""" +import sys from typing import Any from typing import Dict from typing import Mapping from typing import Optional from typing import Tuple +from typing import Type from urllib.parse import urljoin +if sys.version_info >= (3, 8): + from functools import cached_property +else: + from backports.cached_property import cached_property from openapi_core.casting.schemas import schema_casters_factory from openapi_core.casting.schemas.factories import SchemaCastersFactory from openapi_core.deserializing.media_types import ( @@ -23,13 +29,16 @@ from openapi_core.schema.parameters import get_value from openapi_core.spec import Spec from openapi_core.templating.media_types.datatypes import MediaType -from openapi_core.templating.paths.datatypes import ServerOperationPath -from openapi_core.templating.paths.finders import PathFinder +from openapi_core.templating.paths.datatypes import PathOperationServer +from openapi_core.templating.paths.finders import APICallPathFinder +from openapi_core.templating.paths.finders import BasePathFinder +from openapi_core.templating.paths.finders import WebhookPathFinder from openapi_core.unmarshalling.schemas.factories import ( SchemaUnmarshallersFactory, ) from openapi_core.validation.request.protocols import Request from openapi_core.validation.request.protocols import SupportsPathPattern +from openapi_core.validation.request.protocols import WebhookRequest class BaseValidator: @@ -64,12 +73,6 @@ def __init__( media_type_deserializers_factory ) - def _find_path(self, request: Request) -> ServerOperationPath: - path_finder = PathFinder(self.spec, base_url=self.base_url) - path_pattern = getattr(request, "path_pattern", None) or request.path - full_url = urljoin(request.host_url, path_pattern) - return path_finder.find(request.method, full_url) - def _get_media_type(self, content: Spec, mimetype: str) -> MediaType: from openapi_core.templating.media_types.finders import MediaTypeFinder @@ -123,3 +126,23 @@ def _get_param_or_header_value( casted = self._cast(schema, deserialised) unmarshalled = self._unmarshal(schema, casted) return unmarshalled + + +class BaseAPICallValidator(BaseValidator): + @cached_property + def path_finder(self) -> BasePathFinder: + return APICallPathFinder(self.spec, base_url=self.base_url) + + def _find_path(self, request: Request) -> PathOperationServer: + path_pattern = getattr(request, "path_pattern", None) or request.path + full_url = urljoin(request.host_url, path_pattern) + return self.path_finder.find(request.method, full_url) + + +class BaseWebhookValidator(BaseValidator): + @cached_property + def path_finder(self) -> BasePathFinder: + return WebhookPathFinder(self.spec, base_url=self.base_url) + + def _find_path(self, request: WebhookRequest) -> PathOperationServer: + return self.path_finder.find(request.method, request.name) diff --git a/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml b/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml index c7ea6c3a..64758dd8 100644 --- a/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml +++ b/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml @@ -1,4 +1,4 @@ -openapi: "3.0.0" +openapi: "3.1.0" info: title: Basic OpenAPI specification used with requests integration tests version: "0.1" @@ -70,3 +70,37 @@ paths: type: string message: type: string +webhooks: + 'resourceAdded': + parameters: + - name: X-Rate-Limit + in: header + required: true + description: Rate limit + schema: + type: integer + post: + requestBody: + description: Added resource data + required: True + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + 200: + description: Callback complete. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: string diff --git a/tests/integration/contrib/requests/test_requests_validation.py b/tests/integration/contrib/requests/test_requests_validation.py index 9a59d6be..4078807e 100644 --- a/tests/integration/contrib/requests/test_requests_validation.py +++ b/tests/integration/contrib/requests/test_requests_validation.py @@ -2,10 +2,13 @@ import requests import responses -from openapi_core import openapi_request_validator -from openapi_core import openapi_response_validator +from openapi_core import V31RequestValidator +from openapi_core import V31ResponseValidator +from openapi_core import V31WebhookRequestValidator +from openapi_core import V31WebhookResponseValidator from openapi_core.contrib.requests import RequestsOpenAPIRequest from openapi_core.contrib.requests import RequestsOpenAPIResponse +from openapi_core.contrib.requests import RequestsOpenAPIWebhookRequest class TestRequestsOpenAPIValidation: @@ -14,8 +17,24 @@ def spec(self, factory): specfile = "contrib/requests/data/v3.0/requests_factory.yaml" return factory.spec_from_file(specfile) + @pytest.fixture + def request_validator(self, spec): + return V31RequestValidator(spec) + + @pytest.fixture + def response_validator(self, spec): + return V31ResponseValidator(spec) + + @pytest.fixture + def webhook_request_validator(self, spec): + return V31WebhookRequestValidator(spec) + + @pytest.fixture + def webhook_response_validator(self, spec): + return V31WebhookResponseValidator(spec) + @responses.activate - def test_response_validator_path_pattern(self, spec): + def test_response_validator_path_pattern(self, response_validator): responses.add( responses.POST, "http://localhost/browse/12/?q=string", @@ -36,12 +55,10 @@ def test_response_validator_path_pattern(self, spec): response = session.send(request_prepared) openapi_request = RequestsOpenAPIRequest(request) openapi_response = RequestsOpenAPIResponse(response) - result = openapi_response_validator.validate( - spec, openapi_request, openapi_response - ) + result = response_validator.validate(openapi_request, openapi_response) assert not result.errors - def test_request_validator_path_pattern(self, spec): + def test_request_validator_path_pattern(self, request_validator): request = requests.Request( "POST", "http://localhost/browse/12/", @@ -50,10 +67,10 @@ def test_request_validator_path_pattern(self, spec): json={"param1": 1}, ) openapi_request = RequestsOpenAPIRequest(request) - result = openapi_request_validator.validate(spec, openapi_request) + result = request_validator.validate(openapi_request) assert not result.errors - def test_request_validator_prepared_request(self, spec): + def test_request_validator_prepared_request(self, request_validator): request = requests.Request( "POST", "http://localhost/browse/12/", @@ -63,5 +80,50 @@ def test_request_validator_prepared_request(self, spec): ) request_prepared = request.prepare() openapi_request = RequestsOpenAPIRequest(request_prepared) - result = openapi_request_validator.validate(spec, openapi_request) + result = request_validator.validate(openapi_request) + assert not result.errors + + def test_webhook_request_validator_path(self, webhook_request_validator): + request = requests.Request( + "POST", + "http://otherhost/callback/", + headers={ + "content-type": "application/json", + "X-Rate-Limit": "12", + }, + json={"id": 1}, + ) + openapi_webhook_request = RequestsOpenAPIWebhookRequest( + request, "resourceAdded" + ) + result = webhook_request_validator.validate(openapi_webhook_request) + assert not result.errors + + @responses.activate + def test_webhook_response_validator_path(self, webhook_response_validator): + responses.add( + responses.POST, + "http://otherhost/callback/", + json={"data": "data"}, + status=200, + ) + request = requests.Request( + "POST", + "http://otherhost/callback/", + headers={ + "content-type": "application/json", + "X-Rate-Limit": "12", + }, + json={"id": 1}, + ) + request_prepared = request.prepare() + session = requests.Session() + response = session.send(request_prepared) + openapi_webhook_request = RequestsOpenAPIWebhookRequest( + request, "resourceAdded" + ) + openapi_response = RequestsOpenAPIResponse(response) + result = webhook_response_validator.validate( + openapi_webhook_request, openapi_response + ) assert not result.errors diff --git a/tests/unit/security/test_providers.py b/tests/unit/security/test_providers.py index 8f110c5a..e75ed371 100644 --- a/tests/unit/security/test_providers.py +++ b/tests/unit/security/test_providers.py @@ -35,6 +35,6 @@ def test_header(self, header, scheme): scheme = Spec.from_dict(spec, validator=None) provider = HttpProvider(scheme) - result = provider(request) + result = provider(request.parameters) assert result == value diff --git a/tests/unit/templating/test_paths_finders.py b/tests/unit/templating/test_paths_finders.py index 183dd9a3..30ee5ff9 100644 --- a/tests/unit/templating/test_paths_finders.py +++ b/tests/unit/templating/test_paths_finders.py @@ -5,7 +5,7 @@ from openapi_core.templating.paths.exceptions import OperationNotFound from openapi_core.templating.paths.exceptions import PathNotFound from openapi_core.templating.paths.exceptions import ServerNotFound -from openapi_core.templating.paths.finders import PathFinder +from openapi_core.templating.paths.finders import APICallPathFinder from openapi_core.testing import MockRequest @@ -132,7 +132,7 @@ def spec(self, info, paths, servers): @pytest.fixture def finder(self, spec): - return PathFinder(spec) + return APICallPathFinder(spec) class BaseTestPathServer(BaseTestSpecServer): diff --git a/tests/unit/validation/test_shortcuts.py b/tests/unit/validation/test_shortcuts.py index b48406ea..31c21362 100644 --- a/tests/unit/validation/test_shortcuts.py +++ b/tests/unit/validation/test_shortcuts.py @@ -5,17 +5,39 @@ from openapi_core import validate_request from openapi_core import validate_response from openapi_core.testing.datatypes import ResultMock +from openapi_core.validation.exceptions import ValidatorDetectError +from openapi_core.validation.request.protocols import Request +from openapi_core.validation.request.protocols import WebhookRequest from openapi_core.validation.request.validators import RequestValidator +from openapi_core.validation.request.validators import WebhookRequestValidator +from openapi_core.validation.response.protocols import Response from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.validation.response.validators import ( + WebhookResponseValidator, +) class TestValidateRequest: + def test_spec_not_detected(self): + spec = {} + request = mock.Mock(spec=Request) + + with pytest.raises(ValidatorDetectError): + validate_request(request, spec=spec) + + def test_request_type_error(self): + spec = {"openapi": "3.1"} + request = mock.sentinel.request + + with pytest.raises(TypeError): + validate_request(request, spec=spec) + @mock.patch( "openapi_core.validation.request.validators.RequestValidator.validate", ) - def test_valid(self, mock_validate): + def test_request(self, mock_validate): spec = {"openapi": "3.1"} - request = mock.sentinel.request + request = mock.Mock(spec=Request) result = validate_request(request, spec=spec) @@ -25,9 +47,9 @@ def test_valid(self, mock_validate): @mock.patch( "openapi_core.validation.request.validators.RequestValidator.validate", ) - def test_error(self, mock_validate): + def test_request_error(self, mock_validate): spec = {"openapi": "3.1"} - request = mock.sentinel.request + request = mock.Mock(spec=Request) mock_validate.return_value = ResultMock(error_to_raise=ValueError) with pytest.raises(ValueError): @@ -37,7 +59,7 @@ def test_error(self, mock_validate): def test_validator(self): spec = mock.sentinel.spec - request = mock.sentinel.request + request = mock.Mock(spec=Request) validator = mock.Mock(spec=RequestValidator) with pytest.warns(DeprecationWarning): @@ -46,9 +68,9 @@ def test_validator(self): assert result == validator.validate.return_value validator.validate.aasert_called_once_with(request) - def test_cls(self): + def test_validator_cls(self): spec = mock.sentinel.spec - request = mock.sentinel.request + request = mock.Mock(spec=Request) validator_cls = mock.Mock(spec=RequestValidator) result = validate_request(request, spec=spec, cls=validator_cls) @@ -56,28 +78,98 @@ def test_cls(self): assert result == validator_cls().validate.return_value validator_cls().validate.aasert_called_once_with(request) + @mock.patch( + "openapi_core.validation.request.validators.WebhookRequestValidator." + "validate", + ) + def test_webhook_request(self, mock_validate): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=WebhookRequest) + + result = validate_request(request, spec=spec) + + assert result == mock_validate.return_value + mock_validate.validate.aasert_called_once_with(request) + + def test_webhook_request_validator_not_found(self): + spec = {"openapi": "3.0"} + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(ValidatorDetectError): + validate_request(request, spec=spec) -class TestSpecValidateData: @mock.patch( - "openapi_core.validation.response.validators.ResponseValidator.validate", + "openapi_core.validation.request.validators.WebhookRequestValidator." + "validate", ) - def test_valid(self, mock_validate): + def test_webhook_request_error(self, mock_validate): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=WebhookRequest) + mock_validate.return_value = ResultMock(error_to_raise=ValueError) + + with pytest.raises(ValueError): + validate_request(request, spec=spec) + + mock_validate.aasert_called_once_with(request) + + def test_webhook_validator_cls(self): + spec = mock.sentinel.spec + request = mock.Mock(spec=WebhookRequest) + validator_cls = mock.Mock(spec=WebhookRequestValidator) + + result = validate_request(request, spec=spec, cls=validator_cls) + + assert result == validator_cls().validate.return_value + validator_cls().validate.aasert_called_once_with(request) + + +class TestValidateResponse: + def test_spec_not_detected(self): + spec = {} + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(ValidatorDetectError): + validate_response(request, response, spec=spec) + + def test_request_type_error(self): spec = {"openapi": "3.1"} request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec) + + def test_response_type_error(self): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=Request) response = mock.sentinel.response + with pytest.raises(TypeError): + validate_response(request, response, spec=spec) + + @mock.patch( + "openapi_core.validation.response.validators.ResponseValidator." + "validate", + ) + def test_request_response(self, mock_validate): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + result = validate_response(request, response, spec=spec) assert result == mock_validate.return_value mock_validate.aasert_called_once_with(request, response) @mock.patch( - "openapi_core.validation.response.validators.ResponseValidator.validate", + "openapi_core.validation.response.validators.ResponseValidator." + "validate", ) - def test_error(self, mock_validate): + def test_request_response_error(self, mock_validate): spec = {"openapi": "3.1"} - request = mock.sentinel.request - response = mock.sentinel.response + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) mock_validate.return_value = ResultMock(error_to_raise=ValueError) with pytest.raises(ValueError): @@ -87,8 +179,8 @@ def test_error(self, mock_validate): def test_validator(self): spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) validator = mock.Mock(spec=ResponseValidator) with pytest.warns(DeprecationWarning): @@ -99,10 +191,10 @@ def test_validator(self): assert result == validator.validate.return_value validator.validate.aasert_called_once_with(request) - def test_cls(self): + def test_validator_cls(self): spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) validator_cls = mock.Mock(spec=ResponseValidator) result = validate_response( @@ -111,3 +203,53 @@ def test_cls(self): assert result == validator_cls().validate.return_value validator_cls().validate.aasert_called_once_with(request) + + def test_webhook_response_validator_not_found(self): + spec = {"openapi": "3.0"} + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(ValidatorDetectError): + validate_response(request, response, spec=spec) + + @mock.patch( + "openapi_core.validation.response.validators.WebhookResponseValidator." + "validate", + ) + def test_webhook_request(self, mock_validate): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + result = validate_response(request, response, spec=spec) + + assert result == mock_validate.return_value + mock_validate.aasert_called_once_with(request, response) + + @mock.patch( + "openapi_core.validation.response.validators.WebhookResponseValidator." + "validate", + ) + def test_webhook_request_error(self, mock_validate): + spec = {"openapi": "3.1"} + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + mock_validate.return_value = ResultMock(error_to_raise=ValueError) + + with pytest.raises(ValueError): + validate_response(request, response, spec=spec) + + mock_validate.aasert_called_once_with(request, response) + + def test_webhook_response_cls(self): + spec = mock.sentinel.spec + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + validator_cls = mock.Mock(spec=WebhookResponseValidator) + + result = validate_response( + request, response, spec=spec, cls=validator_cls + ) + + assert result == validator_cls().validate.return_value + validator_cls().validate.aasert_called_once_with(request)