diff --git a/docs/validation.md b/docs/validation.md index 7ec8d75c..72656df5 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -45,13 +45,41 @@ The webhook request object should implement the OpenAPI WebhookRequest protocol You can also define your own request validator (See [Request Validator](configuration.md#request-validator)). +### Iterating request errors + +If you want to collect errors instead of raising on the first one, use iterator-based APIs: + +```python +errors = list(openapi.iter_request_errors(request)) +if errors: + for error in errors: + print(type(error), str(error)) +``` + +You can also call `iter_errors` directly on a validator class: + +```python +from openapi_core import V31RequestValidator + +errors = list(V31RequestValidator(spec).iter_errors(request)) +``` + +Some high-level errors wrap detailed schema errors. To access nested schema details: + +```python +for error in openapi.iter_request_errors(request): + cause = getattr(error, "__cause__", None) + schema_errors = getattr(cause, "schema_errors", None) + if schema_errors: + for schema_error in schema_errors: + print(schema_error.message) +``` + ## Response validation Use the `validate_response` function to validate response data against a given spec. By default, the OpenAPI spec version is detected: ```python -from openapi_core import validate_response - # raises error if response is invalid openapi.validate_response(request, response) ``` @@ -70,3 +98,11 @@ openapi.validate_response(webhook_request, response) ``` You can also define your own response validator (See [Response Validator](configuration.md#response-validator)). + +### Iterating response errors + +Use `iter_response_errors` to collect validation errors for a response: + +```python +errors = list(openapi.iter_response_errors(request, response)) +``` diff --git a/openapi_core/__init__.py b/openapi_core/__init__.py index e6591e0a..b5a72e95 100644 --- a/openapi_core/__init__.py +++ b/openapi_core/__init__.py @@ -2,6 +2,12 @@ from openapi_core.app import OpenAPI from openapi_core.configurations import Config +from openapi_core.shortcuts import iter_apicall_request_errors +from openapi_core.shortcuts import iter_apicall_response_errors +from openapi_core.shortcuts import iter_request_errors +from openapi_core.shortcuts import iter_response_errors +from openapi_core.shortcuts import iter_webhook_request_errors +from openapi_core.shortcuts import iter_webhook_response_errors from openapi_core.shortcuts import unmarshal_apicall_request from openapi_core.shortcuts import unmarshal_apicall_response from openapi_core.shortcuts import unmarshal_request @@ -64,6 +70,12 @@ "validate_webhook_response", "validate_request", "validate_response", + "iter_apicall_request_errors", + "iter_webhook_request_errors", + "iter_apicall_response_errors", + "iter_webhook_response_errors", + "iter_request_errors", + "iter_response_errors", "V30RequestUnmarshaller", "V30ResponseUnmarshaller", "V31RequestUnmarshaller", diff --git a/openapi_core/app.py b/openapi_core/app.py index 38e1e6d2..d9336960 100644 --- a/openapi_core/app.py +++ b/openapi_core/app.py @@ -3,6 +3,7 @@ from functools import cached_property from pathlib import Path from typing import Any +from typing import Iterator from typing import Optional from jsonschema._utils import Unset @@ -590,6 +591,32 @@ def validate_request( else: self.validate_apicall_request(request) + def iter_request_errors( + self, + request: Annotated[ + AnyRequest, + Doc(""" + Request object to be validated. + """), + ], + ) -> Iterator[Exception]: + """Iterates over request validation errors. + + Args: + request (AnyRequest): Request object to be validated. + + Returns: + Iterator[Exception]: Iterator over request validation errors. + + Raises: + TypeError: If the request object is not of the expected type. + SpecError: If the validator class is not found. + """ + if isinstance(request, WebhookRequest): + return self.iter_webhook_request_errors(request) + else: + return self.iter_apicall_request_errors(request) + def validate_response( self, request: Annotated[ @@ -620,6 +647,39 @@ def validate_response( else: self.validate_apicall_response(request, response) + def iter_response_errors( + self, + request: Annotated[ + AnyRequest, + Doc(""" + Request object associated with the response. + """), + ], + response: Annotated[ + Response, + Doc(""" + Response object to be validated. + """), + ], + ) -> Iterator[Exception]: + """Iterates over response validation errors. + + Args: + request (AnyRequest): Request object associated with the response. + response (Response): Response object to be validated. + + Returns: + Iterator[Exception]: Iterator over response validation errors. + + Raises: + TypeError: If the request or response object is not of the expected type. + SpecError: If the validator class is not found. + """ + if isinstance(request, WebhookRequest): + return self.iter_webhook_response_errors(request, response) + else: + return self.iter_apicall_response_errors(request, response) + def validate_apicall_request( self, request: Annotated[ @@ -633,6 +693,19 @@ def validate_apicall_request( raise TypeError("'request' argument is not type of Request") self.request_validator.validate(request) + def iter_apicall_request_errors( + self, + request: Annotated[ + Request, + Doc(""" + API call request object to be validated. + """), + ], + ) -> Iterator[Exception]: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + return self.request_validator.iter_errors(request) + def validate_apicall_response( self, request: Annotated[ @@ -654,6 +727,27 @@ def validate_apicall_response( raise TypeError("'response' argument is not type of Response") self.response_validator.validate(request, response) + def iter_apicall_response_errors( + self, + request: Annotated[ + Request, + Doc(""" + API call request object associated with the response. + """), + ], + response: Annotated[ + Response, + Doc(""" + API call response object to be validated. + """), + ], + ) -> Iterator[Exception]: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + return self.response_validator.iter_errors(request, response) + def validate_webhook_request( self, request: Annotated[ @@ -667,6 +761,19 @@ def validate_webhook_request( raise TypeError("'request' argument is not type of WebhookRequest") self.webhook_request_validator.validate(request) + def iter_webhook_request_errors( + self, + request: Annotated[ + WebhookRequest, + Doc(""" + Webhook request object to be validated. + """), + ], + ) -> Iterator[Exception]: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + return self.webhook_request_validator.iter_errors(request) + def validate_webhook_response( self, request: Annotated[ @@ -688,6 +795,27 @@ def validate_webhook_response( raise TypeError("'response' argument is not type of Response") self.webhook_response_validator.validate(request, response) + def iter_webhook_response_errors( + self, + request: Annotated[ + WebhookRequest, + Doc(""" + Webhook request object associated with the response. + """), + ], + response: Annotated[ + Response, + Doc(""" + Webhook response object to be validated. + """), + ], + ) -> Iterator[Exception]: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + return self.webhook_response_validator.iter_errors(request, response) + def unmarshal_request( self, request: Annotated[ diff --git a/openapi_core/shortcuts.py b/openapi_core/shortcuts.py index be5c69f9..39bc2b4f 100644 --- a/openapi_core/shortcuts.py +++ b/openapi_core/shortcuts.py @@ -1,6 +1,7 @@ """OpenAPI core shortcuts module""" from typing import Any +from typing import Iterator from typing import Optional from typing import Union @@ -164,6 +165,22 @@ def validate_request( return OpenAPI(spec, config=config).validate_request(request) +def iter_request_errors( + request: AnyRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyRequestValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + request_validator_cls=cls or _UNSET, + webhook_request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_request_errors(request) + + def validate_response( request: Union[Request, WebhookRequest], response: Response, @@ -181,6 +198,23 @@ def validate_response( return OpenAPI(spec, config=config).validate_response(request, response) +def iter_response_errors( + request: Union[Request, WebhookRequest], + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyResponseValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + response_validator_cls=cls or _UNSET, + webhook_response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_response_errors(request, response) + + def validate_apicall_request( request: Request, spec: SchemaPath, @@ -196,6 +230,21 @@ def validate_apicall_request( return OpenAPI(spec, config=config).validate_apicall_request(request) +def iter_apicall_request_errors( + request: Request, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[RequestValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_apicall_request_errors(request) + + def validate_webhook_request( request: WebhookRequest, spec: SchemaPath, @@ -211,6 +260,21 @@ def validate_webhook_request( return OpenAPI(spec, config=config).validate_webhook_request(request) +def iter_webhook_request_errors( + request: WebhookRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookRequestValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + webhook_request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_webhook_request_errors(request) + + def validate_apicall_response( request: Request, response: Response, @@ -229,6 +293,24 @@ def validate_apicall_response( ) +def iter_apicall_response_errors( + request: Request, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[ResponseValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_apicall_response_errors( + request, response + ) + + def validate_webhook_response( request: WebhookRequest, response: Response, @@ -245,3 +327,21 @@ def validate_webhook_response( return OpenAPI(spec, config=config).validate_webhook_response( request, response ) + + +def iter_webhook_response_errors( + request: WebhookRequest, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookResponseValidatorType] = None, + **validator_kwargs: Any, +) -> Iterator[Exception]: + config = Config( + server_base_url=base_url, + webhook_response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).iter_webhook_response_errors( + request, response + ) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 1457f726..3ba84ab0 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -1,4 +1,5 @@ from pathlib import Path +from unittest import mock import pytest @@ -9,6 +10,9 @@ from openapi_core import V3ResponseUnmarshaller from openapi_core import V3ResponseValidator from openapi_core.exceptions import SpecError +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest from openapi_core.unmarshalling.request import V32RequestUnmarshaller from openapi_core.unmarshalling.request import V32WebhookRequestUnmarshaller from openapi_core.unmarshalling.response import V32ResponseUnmarshaller @@ -146,3 +150,67 @@ def test_default_webhook_response_unmarshaller(self, spec_v32): result.webhook_response_unmarshaller_cls is V32WebhookResponseUnmarshaller ) + + +class TestOpenAPIIterErrors: + @mock.patch( + "openapi_core.validation.request.validators.V32RequestValidator." + "iter_errors", + ) + def test_iter_apicall_request_errors(self, mock_iter_errors, spec_v32): + openapi = OpenAPI(spec_v32) + request = mock.Mock(spec=Request) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = openapi.iter_apicall_request_errors(request) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + @mock.patch( + "openapi_core.validation.request.validators.V32WebhookRequestValidator." + "iter_errors", + ) + def test_iter_request_errors_webhook(self, mock_iter_errors, spec_v32): + openapi = OpenAPI(spec_v32) + request = mock.Mock(spec=WebhookRequest) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = openapi.iter_request_errors(request) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + @mock.patch( + "openapi_core.validation.response.validators.V32ResponseValidator." + "iter_errors", + ) + def test_iter_apicall_response_errors(self, mock_iter_errors, spec_v32): + openapi = OpenAPI(spec_v32) + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = openapi.iter_apicall_response_errors(request, response) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response) + + @mock.patch( + "openapi_core.validation.response.validators.V32WebhookResponseValidator." + "iter_errors", + ) + def test_iter_response_errors_webhook(self, mock_iter_errors, spec_v32): + openapi = OpenAPI(spec_v32) + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = openapi.iter_response_errors(request, response) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response) diff --git a/tests/unit/test_shortcuts.py b/tests/unit/test_shortcuts.py index 9a3f36c9..2611bef8 100644 --- a/tests/unit/test_shortcuts.py +++ b/tests/unit/test_shortcuts.py @@ -3,6 +3,12 @@ import pytest from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_core import iter_apicall_request_errors +from openapi_core import iter_apicall_response_errors +from openapi_core import iter_request_errors +from openapi_core import iter_response_errors +from openapi_core import iter_webhook_request_errors +from openapi_core import iter_webhook_response_errors from openapi_core import unmarshal_apicall_request from openapi_core import unmarshal_apicall_response from openapi_core import unmarshal_request @@ -540,6 +546,22 @@ def test_request(self, mock_validate, spec_v31): mock_validate.assert_called_once_with(request) +class TestIterAPICallRequestErrors: + @mock.patch( + "openapi_core.validation.request.validators.APICallRequestValidator." + "iter_errors", + ) + def test_request(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=Request) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_apicall_request_errors(request, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + class TestValidateWebhookRequest: def test_spec_not_detected(self, spec_invalid): request = mock.Mock(spec=WebhookRequest) @@ -591,6 +613,22 @@ def test_request(self, mock_validate, spec_v31): mock_validate.assert_called_once_with(request) +class TestIterWebhookRequestErrors: + @mock.patch( + "openapi_core.validation.request.validators.WebhookRequestValidator." + "iter_errors", + ) + def test_request(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=WebhookRequest) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_webhook_request_errors(request, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + class TestValidateRequest: def test_spec_invalid(self, spec_invalid): request = mock.Mock(spec=Request) @@ -752,6 +790,36 @@ def test_webhook_cls_invalid(self, spec_v31): validate_request(request, spec=spec_v31, cls=Exception) +class TestIterRequestErrors: + @mock.patch( + "openapi_core.validation.request.validators.APICallRequestValidator." + "iter_errors", + ) + def test_request(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=Request) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_request_errors(request, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + @mock.patch( + "openapi_core.validation.request.validators.V31WebhookRequestValidator." + "iter_errors", + ) + def test_webhook_request(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=WebhookRequest) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_request_errors(request, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request) + + class TestValidateAPICallResponse: def test_spec_not_detected(self, spec_invalid): request = mock.Mock(spec=Request) @@ -812,6 +880,23 @@ def test_request_response(self, mock_validate, spec_v31): mock_validate.assert_called_once_with(request, response) +class TestIterAPICallResponseErrors: + @mock.patch( + "openapi_core.validation.response.validators.APICallResponseValidator." + "iter_errors", + ) + def test_request_response(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_apicall_response_errors(request, response, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response) + + class TestValidateWebhookResponse: def test_spec_not_detected(self, spec_invalid): request = mock.Mock(spec=WebhookRequest) @@ -879,6 +964,23 @@ def test_request_response(self, mock_validate, spec_v31): mock_validate.assert_called_once_with(request, response) +class TestIterWebhookResponseErrors: + @mock.patch( + "openapi_core.validation.response.validators.WebhookResponseValidator." + "iter_errors", + ) + def test_request_response(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_webhook_response_errors(request, response, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response) + + class TestValidateResponse: def test_spec_not_detected(self, spec_invalid): request = mock.Mock(spec=Request) @@ -1012,3 +1114,35 @@ def test_webhook_cls_type_invalid(self, spec_v31): with pytest.raises(TypeError): validate_response(request, response, spec=spec_v31, cls=Exception) + + +class TestIterResponseErrors: + @mock.patch( + "openapi_core.validation.response.validators.APICallResponseValidator." + "iter_errors", + ) + def test_request_response(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_response_errors(request, response, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response) + + @mock.patch( + "openapi_core.validation.response.validators.V31WebhookResponseValidator." + "iter_errors", + ) + def test_webhook_request(self, mock_iter_errors, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + errors_iter = iter([ValueError("oops")]) + mock_iter_errors.return_value = errors_iter + + result = iter_response_errors(request, response, spec=spec_v31) + + assert result is errors_iter + mock_iter_errors.assert_called_once_with(request, response)