Skip to content

Commit 78e654f

Browse files
authored
v20/3x - raise on http status 4xx and 5xx
customizable, docs & tests
1 parent 2f06443 commit 78e654f

File tree

18 files changed

+221
-39
lines changed

18 files changed

+221
-39
lines changed

aiopenapi3/errors.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import dataclasses
44

55
import httpx
6-
6+
import pydantic
77

88
if typing.TYPE_CHECKING:
99
from ._types import (
@@ -187,3 +187,29 @@ class HeadersMissingError(ResponseError):
187187
def __str__(self):
188188
return f"""<{self.__class__.__name__} {self.response.request.method} '{self.response.request.url.path}' ({self.operation.operationId})
189189
{self.missing}>"""
190+
191+
192+
@dataclasses.dataclass(repr=False)
193+
class HTTPStatusIndicatedError(HTTPError):
194+
"""The HTTP Status is 4xx or 5xx"""
195+
196+
status_code: int
197+
headers: dict[str, str]
198+
data: pydantic.BaseModel
199+
200+
def __str__(self):
201+
return f"""<{self.__class__.__name__} {self.status_code} {self.data} {self.headers}>"""
202+
203+
204+
@dataclasses.dataclass(repr=False)
205+
class HTTPClientError(HTTPStatusIndicatedError):
206+
"""response code 4xx"""
207+
208+
pass
209+
210+
211+
@dataclasses.dataclass(repr=False)
212+
class HTTPServerError(HTTPStatusIndicatedError):
213+
"""response code 5xx"""
214+
215+
pass

aiopenapi3/openapi.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from . import v31
2929
from . import log
3030
from .request import OperationIndex, HTTP_METHODS
31-
from .errors import ReferenceResolutionError
31+
from .errors import ReferenceResolutionError, HTTPClientError, HTTPServerError
3232
from .loader import Loader, NullLoader
3333
from .plugin import Plugin, Plugins
3434
from .base import RootBase, ReferenceBase, SchemaBase, OperationBase, DiscriminatorBase
@@ -266,6 +266,14 @@ def __init__(
266266
Maximum Content-Length in Responses - default to 8 MBytes
267267
"""
268268

269+
self.raise_on_http_status: list[tuple[type[Exception], tuple[int, int]]] = [
270+
(HTTPClientError, (400, 499)),
271+
(HTTPServerError, (500, 599)),
272+
]
273+
"""
274+
Raise for http status code
275+
"""
276+
269277
self._security: dict[str, tuple[str]] = dict()
270278
"""
271279
authorization informations

aiopenapi3/request.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ async def aclosing(thing):
2727

2828
from .base import HTTP_METHODS, ReferenceBase
2929
from .version import __version__
30-
from .errors import RequestError, OperationIdDuplicationError
31-
30+
from .errors import RequestError, OperationIdDuplicationError, HTTPServerError, HTTPClientError
3231

3332
if typing.TYPE_CHECKING:
3433
from ._types import (
@@ -218,6 +217,11 @@ def _build_req(self, session: Union[httpx.Client, httpx.AsyncClient]) -> httpx.R
218217
)
219218
return req
220219

220+
def _raise_on_http_status(self, status_code: int, headers: dict[str, str], data: Union[pydantic.BaseModel, bytes]):
221+
for exc, (start, end) in self.api.raise_on_http_status:
222+
if start <= status_code <= end:
223+
raise exc(status_code, headers, data)
224+
221225
def request(
222226
self,
223227
data: Optional["RequestData"] = None,

aiopenapi3/v20/glue.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ..request import RequestBase, AsyncRequestBase
1818
from ..errors import HTTPStatusError, ContentTypeError, ResponseSchemaError, ResponseDecodingError, HeadersMissingError
1919

20+
2021
from .parameter import Parameter
2122
from .root import Root
2223

@@ -348,9 +349,13 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType
348349
data = self.api.plugins.message.unmarshalled(
349350
request=self, operationId=self.operation.operationId, unmarshalled=data
350351
).unmarshalled
352+
353+
self._raise_on_http_status(int(status_code), rheaders, data)
354+
351355
return rheaders, data
352356
elif self.operation.produces and content_type in self.operation.produces:
353-
return rheaders, result.content
357+
self._raise_on_http_status(result.status_code, rheaders, ctx.received)
358+
return rheaders, ctx.received
354359
else:
355360
raise ContentTypeError(
356361
self.operation,

aiopenapi3/v30/glue.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,13 +576,18 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType
576576
data = self.api.plugins.message.unmarshalled(
577577
request=self, operationId=self.operation.operationId, unmarshalled=data
578578
).unmarshalled
579+
580+
self._raise_on_http_status(int(status_code), rheaders, data)
581+
579582
return rheaders, data
580583
else:
581584
"""
582585
We have received a valid (i.e. expected) content type,
583586
e.g. application/octet-stream
584587
but we can't validate it since it's not json.
585588
"""
589+
self._raise_on_http_status(result.status_code, rheaders, ctx.received)
590+
586591
return rheaders, ctx.received
587592

588593

docs/source/api.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ API
77
General
88
=======
99
.. autoclass:: aiopenapi3.OpenAPI
10-
:members: authenticate, createRequest, load_async, load_file, load_sync, loads, clone, cache_load, cache_store, _
10+
:members: authenticate, createRequest, load_async, load_file, load_sync, loads, clone, cache_load, cache_store, _, raise_on_http_status
1111

1212

1313
Requests
@@ -266,7 +266,7 @@ Exceptions
266266

267267
There is different types of Exceptions used depending on the subsystem/failure.
268268

269-
.. 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
269+
.. 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
270270
:top-classes: aiopenapi3.errors.BaseError
271271
:parts: -2
272272

@@ -351,6 +351,24 @@ document.
351351
:members:
352352
:undoc-members:
353353

354+
HTTP Status
355+
-----------
356+
.. inheritance-diagram:: aiopenapi3.errors.HTTPStatusIndicatedError aiopenapi3.errors.HTTPClientError aiopenapi3.errors.HTTPServerError
357+
:top-classes: aiopenapi3.errors.HTTPStatusIndicatedError
358+
:parts: -2
359+
360+
.. autoexception:: HTTPStatusIndicatedError
361+
:members:
362+
:undoc-members:
363+
364+
.. autoexception:: HTTPClientError
365+
:members:
366+
:undoc-members:
367+
368+
.. autoexception:: HTTPServerError
369+
:members:
370+
:undoc-members:
371+
354372
Extra
355373
=====
356374

docs/source/use.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ And …
188188
189189
api._.repoDelete(parameters={"owner":user.login, "repo":"rtd"})
190190
191+
192+
Request Errors
193+
--------------
194+
195+
Starting version 0.8.0 aiopenapi3 :ref:`raises <api:HTTP Status>` HTTPClientError for 400 <= http_code <= 499 and HTTPServerError for 500 to 599.
196+
197+
This is customizable via :obj:`aiopenapi3.OpenAPI.raise_on_http_status`.
198+
191199
async
192200
=====
193201
Difference when using asyncio - await.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ tests = [
107107
"pytest-asyncio>=0.24.0",
108108
"pytest-httpx",
109109
"pytest-cov",
110+
"pytest-mock",
110111
"fastapi",
111112
"fastapi-versioning",
112113
"uvloop == 0.21.0b1; python_version >= '3.13'",

tests/api/v2/main.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from fastapi_versioning import versioned_api_route
1414

15+
from pydantic import RootModel
16+
1517
router = APIRouter(route_class=versioned_api_route(2))
1618

1719
ZOO = dict()
@@ -57,9 +59,9 @@ def listPet(limit: Optional[int] = None) -> schema.Pets:
5759

5860
@router.get("/pets/{petId}", operation_id="getPet", response_model=schema.Pet, responses={404: {"model": schema.Error}})
5961
def getPet(pet_id: str = Path(..., alias="petId")) -> schema.Pets:
60-
for k, v in ZOO.items():
61-
if pet_id == v.identifier:
62-
return v
62+
for k, pet in ZOO.items():
63+
if pet_id == pet.identifier:
64+
return pet
6365
else:
6466
return JSONResponse(
6567
status_code=starlette.status.HTTP_404_NOT_FOUND,
@@ -73,10 +75,10 @@ def getPet(pet_id: str = Path(..., alias="petId")) -> schema.Pets:
7375
def deletePet(
7476
response: Response,
7577
x_raise_nonexist: Annotated[Union[bool, None], Header()],
76-
pet_id: uuid.UUID = Path(..., alias="petId"),
78+
pet_id: str = Path(..., alias="petId"),
7779
) -> None:
78-
for k, v in ZOO.items():
79-
if pet_id == v.identifier:
80+
for k, pet in ZOO.items():
81+
if pet_id == pet.identifier:
8082
del ZOO[k]
8183
response.status_code = starlette.status.HTTP_204_NO_CONTENT
8284
return response

tests/apiv1_test.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ async def test_createPet(server, client):
5555
assert type(r).model_json_schema() == client.components.schemas["Pet"].get_type().model_json_schema()
5656
assert h["X-Limit-Remain"] == 5
5757

58-
r = await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}})
59-
assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
58+
with pytest.raises(aiopenapi3.errors.HTTPClientError) as e:
59+
await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}})
60+
assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
6061

6162

6263
@pytest.mark.asyncio(loop_scope="session")
@@ -74,15 +75,18 @@ async def test_getPet(server, client):
7475
# assert type(r).model_json_schema() == type(pet).model_json_schema()
7576
assert r.id == pet.id
7677

77-
r = await asyncio.to_thread(client._.getPet, parameters={"petId": -1})
78-
assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
78+
with pytest.raises(aiopenapi3.errors.HTTPClientError) as e:
79+
await asyncio.to_thread(client._.getPet, parameters={"petId": -1})
80+
81+
assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
7982

8083

8184
@pytest.mark.asyncio(loop_scope="session")
8285
async def test_deletePet(server, client):
83-
r = await asyncio.to_thread(client._.deletePet, parameters={"petId": -1})
84-
print(r)
85-
assert type(r).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
86+
with pytest.raises(aiopenapi3.errors.HTTPClientError) as e:
87+
await asyncio.to_thread(client._.deletePet, parameters={"petId": -1})
88+
89+
assert type(e.value.data).model_json_schema() == client.components.schemas["Error"].get_type().model_json_schema()
8690

8791
await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4()))
8892
zoo = await asyncio.to_thread(client._.listPet)

0 commit comments

Comments
 (0)