Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

raise on http status indicated error #306

Merged
merged 9 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion aiopenapi3/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import dataclasses

import httpx

import pydantic

if typing.TYPE_CHECKING:
from ._types import (
Expand Down Expand Up @@ -187,3 +187,29 @@
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}>"""

Check warning on line 201 in aiopenapi3/errors.py

View check run for this annotation

Codecov / codecov/patch

aiopenapi3/errors.py#L201

Added line #L201 was not covered by tests


@dataclasses.dataclass(repr=False)
class HTTPClientError(HTTPStatusIndicatedError):
"""response code 4xx"""

pass


@dataclasses.dataclass(repr=False)
class HTTPServerError(HTTPStatusIndicatedError):
"""response code 5xx"""

pass
10 changes: 9 additions & 1 deletion aiopenapi3/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions aiopenapi3/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion aiopenapi3/v20/glue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -348,9 +349,13 @@
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

Check warning on line 358 in aiopenapi3/v20/glue.py

View check run for this annotation

Codecov / codecov/patch

aiopenapi3/v20/glue.py#L357-L358

Added lines #L357 - L358 were not covered by tests
else:
raise ContentTypeError(
self.operation,
Expand Down
5 changes: 5 additions & 0 deletions aiopenapi3/v30/glue.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,13 +576,18 @@ 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:
"""
We have received a valid (i.e. expected) content type,
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


Expand Down
22 changes: 20 additions & 2 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
=====

Expand Down
8 changes: 8 additions & 0 deletions docs/source/use.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ And …

api._.repoDelete(parameters={"owner":user.login, "repo":"rtd"})


Request Errors
--------------

Starting version 0.8.0 aiopenapi3 :ref:`raises <api:HTTP Status>` 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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
14 changes: 8 additions & 6 deletions tests/api/v2/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
18 changes: 11 additions & 7 deletions tests/apiv1_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
28 changes: 19 additions & 9 deletions tests/apiv2_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
Expand All @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions tests/error_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Loading
Loading