Skip to content

Commit 83dece7

Browse files
committed
v20/3x - raise on http status 4xx and 5xx
1 parent 2f06443 commit 83dece7

File tree

9 files changed

+145
-11
lines changed

9 files changed

+145
-11
lines changed

aiopenapi3/errors.py

Lines changed: 23 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,25 @@ 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+
class HTTPStatusIndicatedError(HTTPError):
193+
"""The HTTP Status is 4xx or 5xx"""
194+
195+
pass
196+
197+
198+
@dataclasses.dataclass(repr=False)
199+
class HttpClientError(HTTPStatusIndicatedError):
200+
"""response code 4xx"""
201+
202+
headers: dict[str, str]
203+
data: pydantic.BaseModel
204+
205+
206+
@dataclasses.dataclass(repr=False)
207+
class HttpServerError(HTTPStatusIndicatedError):
208+
"""response code 5xx"""
209+
210+
headers: dict[str, str]
211+
data: pydantic.BaseModel

aiopenapi3/openapi.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def __init__(
232232
loader: Optional[Loader] = None,
233233
plugins: Optional[list[Plugin]] = None,
234234
use_operation_tags: bool = True,
235+
raise_on_error: bool = True,
235236
) -> None:
236237
"""
237238
Creates a new OpenAPI document from a loaded spec file. This is
@@ -244,6 +245,7 @@ def __init__(
244245
:param loader: the Loader for the description document(s)
245246
:param plugins: list of plugins
246247
:param use_operation_tags: honor tags
248+
:param raise_on_error: raise an exception if the http status code indicates error
247249
"""
248250
self._base_url: yarl.URL = yarl.URL(url)
249251

@@ -266,6 +268,11 @@ def __init__(
266268
Maximum Content-Length in Responses - default to 8 MBytes
267269
"""
268270

271+
self._raise_on_error = raise_on_error
272+
"""
273+
Raise for http status code 400-599
274+
"""
275+
269276
self._security: dict[str, tuple[str]] = dict()
270277
"""
271278
authorization informations

aiopenapi3/request.py

Lines changed: 9 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,14 @@ def _build_req(self, session: Union[httpx.Client, httpx.AsyncClient]) -> httpx.R
218217
)
219218
return req
220219

220+
def _raise_on_error(self, result: httpx.Response, headers: dict[str, str], data: Union[pydantic.BaseModel, bytes]):
221+
if self.api._raise_on_error is False:
222+
return
223+
if 500 <= result.status_code <= 599:
224+
raise HttpServerError(headers, data)
225+
elif 400 <= result.status_code <= 499:
226+
raise HttpClientError(headers, data)
227+
221228
def request(
222229
self,
223230
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_error(result, 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_error(result, 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_error(result, 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_error(result, rheaders, ctx.received)
590+
586591
return rheaders, ctx.received
587592

588593

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -518,9 +518,9 @@ def with_paths_server_variables(openapi_version):
518518
yield _get_parsed_yaml("paths-server-variables.yaml", openapi_version)
519519

520520

521-
@pytest.fixture
522-
def with_paths_response_error():
523-
yield _get_parsed_yaml("paths-response-error.yaml")
521+
@pytest.fixture(params=["", "-v20"], ids=["v3x", "v20"])
522+
def with_paths_response_error_vXX(request):
523+
return _get_parsed_yaml(f"paths-response-error{request.param}.yaml")
524524

525525

526526
@pytest.fixture
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
swagger: "2.0"
2+
info:
3+
title: with response headers
4+
description: with response headers
5+
version: 1.0.0
6+
host: api.example.com
7+
basePath: /v1
8+
schemes:
9+
- https
10+
11+
consumes:
12+
- application/json
13+
produces:
14+
- application/json
15+
16+
definitions: {}
17+
18+
paths:
19+
/test:
20+
get:
21+
operationId: test
22+
responses:
23+
"200":
24+
description: "ok"
25+
schema:
26+
type: string
27+
enum: ["ok"]
28+
29+
"437":
30+
description: "client error"
31+
schema:
32+
type: string
33+
enum: ["ok"]
34+
35+
headers:
36+
X-required:
37+
type: string
38+
39+
"537":
40+
description: "server error"
41+
schema:
42+
type: string
43+
enum: ["ok"]
44+
headers:
45+
X-required:
46+
type: string

tests/fixtures/paths-response-error.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,27 @@ paths:
2020
schema:
2121
type: string
2222
const: ok
23+
"437":
24+
description: "client error"
25+
content:
26+
application/json:
27+
schema:
28+
type: string
29+
const: ok
30+
headers:
31+
X-required:
32+
schema:
33+
type: string
34+
required: true
35+
"537":
36+
description: "server error"
37+
content:
38+
application/json:
39+
schema:
40+
type: string
41+
const: ok
42+
headers:
43+
X-required:
44+
schema:
45+
type: string
46+
required: true

tests/path_test.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
import yarl
1313

1414
from aiopenapi3 import OpenAPI
15-
from aiopenapi3.errors import OperationParameterValidationError, OperationIdDuplicationError, HeadersMissingError
15+
from aiopenapi3.errors import (
16+
OperationParameterValidationError,
17+
OperationIdDuplicationError,
18+
HeadersMissingError,
19+
HttpClientError,
20+
HttpServerError,
21+
)
1622

1723
URLBASE = "/"
1824

@@ -480,7 +486,7 @@ def test_paths_tags(httpx_mock, with_paths_tags):
480486

481487

482488
def test_paths_response_status_pattern_default(httpx_mock, with_paths_response_status_pattern_default):
483-
api = OpenAPI("/", with_paths_response_status_pattern_default, session_factory=httpx.Client)
489+
api = OpenAPI("/", with_paths_response_status_pattern_default, session_factory=httpx.Client, raise_on_error=False)
484490

485491
httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=201, json="created")
486492
r = api._.test()
@@ -505,10 +511,10 @@ def test_paths_response_status_pattern_default(httpx_mock, with_paths_response_s
505511
api._.test()
506512

507513

508-
def test_paths_response_error(httpx_mock, with_paths_response_error):
514+
def test_paths_response_error(httpx_mock, with_paths_response_error_vXX):
509515
from aiopenapi3 import ResponseSchemaError, ContentTypeError, HTTPStatusError, ResponseDecodingError
510516

511-
api = OpenAPI("/", with_paths_response_error, session_factory=httpx.Client)
517+
api = OpenAPI("/", with_paths_response_error_vXX, session_factory=httpx.Client)
512518

513519
httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=200, json="ok")
514520
r = api._.test()
@@ -530,6 +536,18 @@ def test_paths_response_error(httpx_mock, with_paths_response_error):
530536
with pytest.raises(ResponseSchemaError):
531537
api._.test()
532538

539+
httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=437, json="ok")
540+
with pytest.raises(HttpClientError):
541+
api._.test()
542+
543+
httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=537, json="ok")
544+
with pytest.raises(HttpServerError):
545+
api._.test()
546+
547+
httpx_mock.add_response(headers={"Content-Type": "application/json", "X-required": "1"}, status_code=437, json="ok")
548+
api._raise_on_error = False
549+
api._.test()
550+
533551

534552
@pytest.mark.httpx_mock(can_send_already_matched_responses=True)
535553
def test_paths_request_calling(httpx_mock, with_paths_response_status_pattern_default):

0 commit comments

Comments
 (0)