diff --git a/aiopenapi3/errors.py b/aiopenapi3/errors.py index a9b49767..fd426c32 100644 --- a/aiopenapi3/errors.py +++ b/aiopenapi3/errors.py @@ -3,7 +3,7 @@ import dataclasses import httpx - +import pydantic if typing.TYPE_CHECKING: from ._types import ( @@ -187,3 +187,29 @@ class HeadersMissingError(ResponseError): def __str__(self): return f"""<{self.__class__.__name__} {self.response.request.method} '{self.response.request.url.path}' ({self.operation.operationId}) {self.missing}>""" + + +@dataclasses.dataclass(repr=False) +class HTTPStatusIndicatedError(HTTPError): + """The HTTP Status is 4xx or 5xx""" + + status_code: int + headers: dict[str, str] + data: pydantic.BaseModel + + def __str__(self): + return f"""<{self.__class__.__name__} {self.status_code} {self.data} {self.headers}>""" + + +@dataclasses.dataclass(repr=False) +class HTTPClientError(HTTPStatusIndicatedError): + """response code 4xx""" + + pass + + +@dataclasses.dataclass(repr=False) +class HTTPServerError(HTTPStatusIndicatedError): + """response code 5xx""" + + pass diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index cb41ccdc..c807c267 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -28,7 +28,7 @@ from . import v31 from . import log from .request import OperationIndex, HTTP_METHODS -from .errors import ReferenceResolutionError +from .errors import ReferenceResolutionError, HTTPClientError, HTTPServerError from .loader import Loader, NullLoader from .plugin import Plugin, Plugins from .base import RootBase, ReferenceBase, SchemaBase, OperationBase, DiscriminatorBase @@ -266,6 +266,14 @@ def __init__( Maximum Content-Length in Responses - default to 8 MBytes """ + self.raise_on_http_status: list[tuple[type[Exception], tuple[int, int]]] = [ + (HTTPClientError, (400, 499)), + (HTTPServerError, (500, 599)), + ] + """ + Raise for http status code + """ + self._security: dict[str, tuple[str]] = dict() """ authorization informations diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 9ea3d136..1e475a7d 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -27,8 +27,7 @@ async def aclosing(thing): from .base import HTTP_METHODS, ReferenceBase from .version import __version__ -from .errors import RequestError, OperationIdDuplicationError - +from .errors import RequestError, OperationIdDuplicationError, HTTPServerError, HTTPClientError if typing.TYPE_CHECKING: from ._types import ( @@ -218,6 +217,11 @@ def _build_req(self, session: Union[httpx.Client, httpx.AsyncClient]) -> httpx.R ) return req + def _raise_on_http_status(self, status_code: int, headers: dict[str, str], data: Union[pydantic.BaseModel, bytes]): + for exc, (start, end) in self.api.raise_on_http_status: + if start <= status_code <= end: + raise exc(status_code, headers, data) + def request( self, data: Optional["RequestData"] = None, diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index 6ae1798d..f974b80d 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -17,6 +17,7 @@ from ..request import RequestBase, AsyncRequestBase from ..errors import HTTPStatusError, ContentTypeError, ResponseSchemaError, ResponseDecodingError, HeadersMissingError + from .parameter import Parameter from .root import Root @@ -348,9 +349,13 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType data = self.api.plugins.message.unmarshalled( request=self, operationId=self.operation.operationId, unmarshalled=data ).unmarshalled + + self._raise_on_http_status(int(status_code), rheaders, data) + return rheaders, data elif self.operation.produces and content_type in self.operation.produces: - return rheaders, result.content + self._raise_on_http_status(result.status_code, rheaders, ctx.received) + return rheaders, ctx.received else: raise ContentTypeError( self.operation, diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index e181740b..31e3daee 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -576,6 +576,9 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType data = self.api.plugins.message.unmarshalled( request=self, operationId=self.operation.operationId, unmarshalled=data ).unmarshalled + + self._raise_on_http_status(int(status_code), rheaders, data) + return rheaders, data else: """ @@ -583,6 +586,8 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType e.g. application/octet-stream but we can't validate it since it's not json. """ + self._raise_on_http_status(result.status_code, rheaders, ctx.received) + return rheaders, ctx.received diff --git a/docs/source/api.rst b/docs/source/api.rst index 5dfba3db..08faf590 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -7,7 +7,7 @@ API General ======= .. autoclass:: aiopenapi3.OpenAPI - :members: authenticate, createRequest, load_async, load_file, load_sync, loads, clone, cache_load, cache_store, _ + :members: authenticate, createRequest, load_async, load_file, load_sync, loads, clone, cache_load, cache_store, _, raise_on_http_status Requests @@ -266,7 +266,7 @@ Exceptions There is different types of Exceptions used depending on the subsystem/failure. -.. inheritance-diagram:: aiopenapi3.errors.SpecError aiopenapi3.errors.ReferenceResolutionError aiopenapi3.errors.OperationParameterValidationError aiopenapi3.errors.ParameterFormatError aiopenapi3.errors.HTTPError aiopenapi3.errors.RequestError aiopenapi3.errors.ResponseError aiopenapi3.errors.ContentTypeError aiopenapi3.errors.HTTPStatusError aiopenapi3.errors.ResponseDecodingError aiopenapi3.errors.ResponseSchemaError aiopenapi3.errors.ContentLengthExceededError aiopenapi3.errors.HeadersMissingError +.. inheritance-diagram:: aiopenapi3.errors.SpecError aiopenapi3.errors.ReferenceResolutionError aiopenapi3.errors.OperationParameterValidationError aiopenapi3.errors.ParameterFormatError aiopenapi3.errors.HTTPError aiopenapi3.errors.RequestError aiopenapi3.errors.ResponseError aiopenapi3.errors.ContentTypeError aiopenapi3.errors.HTTPStatusError aiopenapi3.errors.ResponseDecodingError aiopenapi3.errors.ResponseSchemaError aiopenapi3.errors.ContentLengthExceededError aiopenapi3.errors.HeadersMissingError aiopenapi3.errors.HTTPStatusIndicatedError aiopenapi3.errors.HTTPClientError aiopenapi3.errors.HTTPServerError :top-classes: aiopenapi3.errors.BaseError :parts: -2 @@ -351,6 +351,24 @@ document. :members: :undoc-members: +HTTP Status +----------- +.. inheritance-diagram:: aiopenapi3.errors.HTTPStatusIndicatedError aiopenapi3.errors.HTTPClientError aiopenapi3.errors.HTTPServerError + :top-classes: aiopenapi3.errors.HTTPStatusIndicatedError + :parts: -2 + +.. autoexception:: HTTPStatusIndicatedError + :members: + :undoc-members: + +.. autoexception:: HTTPClientError + :members: + :undoc-members: + +.. autoexception:: HTTPServerError + :members: + :undoc-members: + Extra ===== diff --git a/docs/source/use.rst b/docs/source/use.rst index 654e6818..3f00ed30 100644 --- a/docs/source/use.rst +++ b/docs/source/use.rst @@ -188,6 +188,14 @@ And … api._.repoDelete(parameters={"owner":user.login, "repo":"rtd"}) + +Request Errors +-------------- + +Starting version 0.8.0 aiopenapi3 :ref:`raises ` HTTPClientError for 400 <= http_code <= 499 and HTTPServerError for 500 to 599. + +This is customizable via :obj:`aiopenapi3.OpenAPI.raise_on_http_status`. + async ===== Difference when using asyncio - await. diff --git a/pyproject.toml b/pyproject.toml index 19aeef6e..f652a133 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ tests = [ "pytest-asyncio>=0.24.0", "pytest-httpx", "pytest-cov", + "pytest-mock", "fastapi", "fastapi-versioning", "uvloop == 0.21.0b1; python_version >= '3.13'", diff --git a/tests/api/v2/main.py b/tests/api/v2/main.py index a43a6bc7..fc447495 100644 --- a/tests/api/v2/main.py +++ b/tests/api/v2/main.py @@ -12,6 +12,8 @@ from fastapi_versioning import versioned_api_route +from pydantic import RootModel + router = APIRouter(route_class=versioned_api_route(2)) ZOO = dict() @@ -57,9 +59,9 @@ def listPet(limit: Optional[int] = None) -> schema.Pets: @router.get("/pets/{petId}", operation_id="getPet", response_model=schema.Pet, responses={404: {"model": schema.Error}}) def getPet(pet_id: str = Path(..., alias="petId")) -> schema.Pets: - for k, v in ZOO.items(): - if pet_id == v.identifier: - return v + for k, pet in ZOO.items(): + if pet_id == pet.identifier: + return pet else: return JSONResponse( status_code=starlette.status.HTTP_404_NOT_FOUND, @@ -73,10 +75,10 @@ def getPet(pet_id: str = Path(..., alias="petId")) -> schema.Pets: def deletePet( response: Response, x_raise_nonexist: Annotated[Union[bool, None], Header()], - pet_id: uuid.UUID = Path(..., alias="petId"), + pet_id: str = Path(..., alias="petId"), ) -> None: - for k, v in ZOO.items(): - if pet_id == v.identifier: + for k, pet in ZOO.items(): + if pet_id == pet.identifier: del ZOO[k] response.status_code = starlette.status.HTTP_204_NO_CONTENT return response diff --git a/tests/apiv1_test.py b/tests/apiv1_test.py index 6ba187f7..047d2523 100644 --- a/tests/apiv1_test.py +++ b/tests/apiv1_test.py @@ -55,8 +55,9 @@ async def test_createPet(server, client): assert type(r).model_json_schema() == client.components.schemas["Pet"].get_type().model_json_schema() assert h["X-Limit-Remain"] == 5 - r = await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}}) - assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}}) + assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() @pytest.mark.asyncio(loop_scope="session") @@ -74,15 +75,18 @@ async def test_getPet(server, client): # assert type(r).model_json_schema() == type(pet).model_json_schema() assert r.id == pet.id - r = await asyncio.to_thread(client._.getPet, parameters={"petId": -1}) - assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await asyncio.to_thread(client._.getPet, parameters={"petId": -1}) + + assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() @pytest.mark.asyncio(loop_scope="session") async def test_deletePet(server, client): - r = await asyncio.to_thread(client._.deletePet, parameters={"petId": -1}) - print(r) - assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await asyncio.to_thread(client._.deletePet, parameters={"petId": -1}) + + assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) zoo = await asyncio.to_thread(client._.listPet) diff --git a/tests/apiv2_test.py b/tests/apiv2_test.py index 58519745..40a5065c 100644 --- a/tests/apiv2_test.py +++ b/tests/apiv2_test.py @@ -180,9 +180,11 @@ async def test_createPet(server, client): r = await client._.createPet(data=data) assert isinstance(r, client.components.schemas["Cat"].get_type()) - r = await client._.createPet(data=randomPet(client, name=r.root.name)) + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await client._.createPet(data=randomPet(client, name=r.root.name)) + Error = client.components.schemas["Error"].get_type() - assert isinstance(r, Error) + assert isinstance(e.value.data, Error) # type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() with pytest.raises(pydantic.ValidationError): @@ -196,8 +198,11 @@ async def test_listPet(server, client): l = await client._.listPet(parameters={"limit": 1}) assert len(l) > 0 - l = await client._.listPet(parameters={"limit": None}) - assert isinstance(l, client.components.schemas["HTTPValidationError"].get_type()) + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await client._.listPet(parameters={"limit": None}) + + Error = client.components.schemas["HTTPValidationError"].get_type() + assert isinstance(e.value.data, Error) @pytest.mark.asyncio(loop_scope="session") @@ -209,21 +214,26 @@ async def test_getPet(server, client): # https://github.com/tiangolo/fastapi/pull/10011 # assert type(r.root).model_json_schema() == type(pet.root).model_json_schema() - r = await client._.getPet(parameters={"petId": "-1"}) - assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await client._.getPet(parameters={"petId": "-1"}) + + assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() @pytest.mark.asyncio(loop_scope="session") async def test_deletePet(server, client): - r = await client._.deletePet(parameters={"petId": uuid.uuid4(), "x-raise-nonexist": False}) - assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() + + with pytest.raises(aiopenapi3.errors.HTTPClientError) as e: + await client._.deletePet(parameters={"petId": str(uuid.uuid4()), "x-raise-nonexist": True}) + + assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema() await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) zoo = await client._.listPet(parameters={"limit": 1}) for pet in zoo: while hasattr(pet, "root"): pet = pet.root - await client._.deletePet(parameters={"petId": pet.identifier, "x-raise-nonexist": None}) + await client._.deletePet(parameters={"petId": pet.identifier, "x-raise-nonexist": False}) @pytest.mark.asyncio(loop_scope="session") diff --git a/tests/conftest.py b/tests/conftest.py index 7502fe9f..912488d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -518,9 +518,9 @@ def with_paths_server_variables(openapi_version): yield _get_parsed_yaml("paths-server-variables.yaml", openapi_version) -@pytest.fixture -def with_paths_response_error(): - yield _get_parsed_yaml("paths-response-error.yaml") +@pytest.fixture(params=["", "-v20"], ids=["v3x", "v20"]) +def with_paths_response_error_vXX(request): + return _get_parsed_yaml(f"paths-response-error{request.param}.yaml") @pytest.fixture diff --git a/tests/error_test.py b/tests/error_test.py index ef8cba5b..7a1b7981 100644 --- a/tests/error_test.py +++ b/tests/error_test.py @@ -7,8 +7,8 @@ import pytest -def test_response_error(httpx_mock, with_paths_response_error): - api = OpenAPI("/", with_paths_response_error, session_factory=httpx.Client) +def test_response_error(httpx_mock, with_paths_response_error_vXX): + api = OpenAPI("/", with_paths_response_error_vXX, session_factory=httpx.Client) httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=200, json="ok") r = api._.test() @@ -35,7 +35,7 @@ def test_response_error(httpx_mock, with_paths_response_error): str(e.value) -def test_request_error(with_paths_response_error): +def test_request_error(with_paths_response_error_vXX): class Client(httpx.Client): def __init__(self, *args, **kwargs): super().__init__(*args, transport=RaisingTransport(), **kwargs) @@ -44,7 +44,7 @@ class RaisingTransport(httpx.BaseTransport): def handle_request(self, request): raise httpx.TimeoutException(message="timeout") - api = OpenAPI("/", with_paths_response_error, session_factory=Client) + api = OpenAPI("/", with_paths_response_error_vXX, session_factory=Client) with pytest.raises(RequestError) as e: api._.test() diff --git a/tests/fixtures/paths-response-error-v20.yaml b/tests/fixtures/paths-response-error-v20.yaml new file mode 100644 index 00000000..3cacb681 --- /dev/null +++ b/tests/fixtures/paths-response-error-v20.yaml @@ -0,0 +1,46 @@ +swagger: "2.0" +info: + title: with response headers + description: with response headers + version: 1.0.0 +host: api.example.com +basePath: /v1 +schemes: + - https + +consumes: + - application/json +produces: + - application/json + +definitions: {} + +paths: + /test: + get: + operationId: test + responses: + "200": + description: "ok" + schema: + type: string + enum: ["ok"] + + "437": + description: "client error" + schema: + type: string + enum: ["ok"] + + headers: + X-required: + type: string + + "537": + description: "server error" + schema: + type: string + enum: ["ok"] + headers: + X-required: + type: string diff --git a/tests/fixtures/paths-response-error.yaml b/tests/fixtures/paths-response-error.yaml index cd34a490..6abdf41c 100644 --- a/tests/fixtures/paths-response-error.yaml +++ b/tests/fixtures/paths-response-error.yaml @@ -20,3 +20,27 @@ paths: schema: type: string const: ok + "437": + description: "client error" + content: + application/json: + schema: + type: string + const: ok + headers: + X-required: + schema: + type: string + required: true + "537": + description: "server error" + content: + application/json: + schema: + type: string + const: ok + headers: + X-required: + schema: + type: string + required: true diff --git a/tests/path_test.py b/tests/path_test.py index ad86bde0..507f3e6a 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -12,7 +12,13 @@ import yarl from aiopenapi3 import OpenAPI -from aiopenapi3.errors import OperationParameterValidationError, OperationIdDuplicationError, HeadersMissingError +from aiopenapi3.errors import ( + OperationParameterValidationError, + OperationIdDuplicationError, + HeadersMissingError, + HTTPClientError, + HTTPServerError, +) URLBASE = "/" @@ -481,6 +487,7 @@ def test_paths_tags(httpx_mock, with_paths_tags): def test_paths_response_status_pattern_default(httpx_mock, with_paths_response_status_pattern_default): api = OpenAPI("/", with_paths_response_status_pattern_default, session_factory=httpx.Client) + api.raise_on_http_status = [] httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=201, json="created") r = api._.test() @@ -505,10 +512,10 @@ def test_paths_response_status_pattern_default(httpx_mock, with_paths_response_s api._.test() -def test_paths_response_error(httpx_mock, with_paths_response_error): +def test_paths_response_error(mocker, httpx_mock, with_paths_response_error_vXX): from aiopenapi3 import ResponseSchemaError, ContentTypeError, HTTPStatusError, ResponseDecodingError - api = OpenAPI("/", with_paths_response_error, session_factory=httpx.Client) + api = OpenAPI("/", with_paths_response_error_vXX, session_factory=httpx.Client) httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=200, json="ok") r = api._.test() @@ -530,6 +537,18 @@ def test_paths_response_error(httpx_mock, with_paths_response_error): with pytest.raises(ResponseSchemaError): api._.test() + httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=437, json="ok") + with pytest.raises(HTTPClientError): + api._.test() + + httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=537, json="ok") + with pytest.raises(HTTPServerError): + api._.test() + + httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=437, json="ok") + mocker.patch.object(api, "raise_on_http_status", return_value=[], autospec=True) + api._.test() + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_paths_request_calling(httpx_mock, with_paths_response_status_pattern_default): diff --git a/tests/petstore_test.py b/tests/petstore_test.py index 12d1e965..128c1ceb 100644 --- a/tests/petstore_test.py +++ b/tests/petstore_test.py @@ -92,6 +92,7 @@ def api(): api = OpenAPI.load_sync( url, plugins=[OnDocument(), OnMessage()], session_factory=session_factory, use_operation_tags=False ) + api.raise_on_http_status = [] api.authenticate(api_key="special-key") return api diff --git a/tests/petstorev3_test.py b/tests/petstorev3_test.py index 8d1dd1c8..1916895b 100644 --- a/tests/petstorev3_test.py +++ b/tests/petstorev3_test.py @@ -112,6 +112,7 @@ def api(): plugins = [OnDocument(), OnMessage()] api = OpenAPI.load_sync(url, plugins=plugins, session_factory=session_factory, use_operation_tags=False) api.authenticate(api_key="special-key") + api.raise_on_http_status = [] return api