Skip to content

Commit f04b1f8

Browse files
feat(parser): #1271 Support simple patterned http status codes
1 parent 8f5f11f commit f04b1f8

File tree

5 files changed

+108
-20
lines changed

5 files changed

+108
-20
lines changed

openapi_python_client/parser/openapi.py

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from collections.abc import Iterator
33
from copy import deepcopy
44
from dataclasses import dataclass, field
5-
from http import HTTPStatus
65
from typing import Any, Optional, Protocol, Union
76

87
from pydantic import ValidationError
@@ -26,7 +25,7 @@
2625
property_from_data,
2726
)
2827
from .properties.schemas import parameter_from_reference
29-
from .responses import Response, response_from_data
28+
from .responses import HTTPStatusSpec, Response, http_status_spec, response_from_data
3029

3130
_PATH_PARAM_REGEX = re.compile("{([a-zA-Z_-][a-zA-Z0-9_-]*)}")
3231

@@ -162,22 +161,13 @@ def _add_responses(
162161
) -> tuple["Endpoint", Schemas]:
163162
endpoint = deepcopy(endpoint)
164163
for code, response_data in data.items():
165-
status_code: HTTPStatus
166-
try:
167-
status_code = HTTPStatus(int(code))
168-
except ValueError:
169-
endpoint.errors.append(
170-
ParseError(
171-
detail=(
172-
f"Invalid response status code {code} (not a valid HTTP "
173-
f"status code), response will be omitted from generated "
174-
f"client"
175-
)
176-
)
177-
)
164+
status_code: HTTPStatusSpec | ParseError = http_status_spec(code)
165+
if isinstance(status_code, ParseError):
166+
endpoint.errors.append(status_code)
178167
continue
179168

180169
response, schemas = response_from_data(
170+
status_code_str=code,
181171
status_code=status_code,
182172
data=response_data,
183173
schemas=schemas,
@@ -190,7 +180,7 @@ def _add_responses(
190180
endpoint.errors.append(
191181
ParseError(
192182
detail=(
193-
f"Cannot parse response for status code {status_code}{detail_suffix}, "
183+
f"Cannot parse response for status code {code}{detail_suffix}, "
194184
f"response will be omitted from generated client"
195185
),
196186
data=response.data,

openapi_python_client/parser/responses.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional, TypedDict, Union
55

66
from attrs import define
7+
from typing_extensions import TypeAlias
78

89
from openapi_python_client import utils
910
from openapi_python_client.parser.properties.schemas import get_reference_simple_name, parse_reference_path
@@ -27,12 +28,48 @@ class _ResponseSource(TypedDict):
2728
TEXT_SOURCE = _ResponseSource(attribute="response.text", return_type="str")
2829
NONE_SOURCE = _ResponseSource(attribute="None", return_type="None")
2930

31+
HTTPStatusSpec: TypeAlias = Union[HTTPStatus, tuple[HTTPStatus, int]]
32+
"""Either a single http status or a tuple representing an inclusive range.
33+
34+
The second element of the tuple is also logically a status code but is typically 299 or similar which
35+
is not contained in the enum.
36+
37+
https://github.com/openapi-generators/openapi-python-client/blob/61b6c54994e2a6285bb422ee3b864c45b5d88c15/openapi_python_client/schema/3.1.0.md#responses-object
38+
"""
39+
40+
41+
def http_status_spec(code: str | int) -> HTTPStatusSpec | ParseError:
42+
"""Parses plain integer status codes such as 201 or patterned status codes such as 2XX."""
43+
44+
multiplier = 1
45+
if isinstance(code, str):
46+
if code.endswith("XX"):
47+
code = code.removesuffix("XX")
48+
multiplier = 100
49+
50+
try:
51+
status_code = int(code)
52+
53+
if multiplier > 1:
54+
start = status_code * multiplier
55+
return (HTTPStatus(start), start + multiplier - 1)
56+
57+
return HTTPStatus(status_code)
58+
except ValueError:
59+
return ParseError(
60+
detail=(
61+
f"Invalid response status code {code} (not a valid HTTP "
62+
f"status code), response will be omitted from generated "
63+
f"client"
64+
)
65+
)
66+
3067

3168
@define
3269
class Response:
3370
"""Describes a single response for an endpoint"""
3471

35-
status_code: HTTPStatus
72+
status_code: HTTPStatusSpec
3673
prop: Property
3774
source: _ResponseSource
3875
data: Union[oai.Response, oai.Reference] # Original data which created this response, useful for custom templates
@@ -59,7 +96,7 @@ def _source_by_content_type(content_type: str, config: Config) -> Optional[_Resp
5996

6097
def empty_response(
6198
*,
62-
status_code: HTTPStatus,
99+
status_code: HTTPStatusSpec,
63100
response_name: str,
64101
config: Config,
65102
data: Union[oai.Response, oai.Reference],
@@ -80,18 +117,32 @@ def empty_response(
80117
)
81118

82119

120+
def _status_code_str(status_code_str: str | None, status_code: HTTPStatusSpec) -> str:
121+
if status_code_str is None:
122+
if isinstance(status_code, HTTPStatus):
123+
return str(status_code.value)
124+
if isinstance(status_code, int):
125+
return str(status_code)
126+
127+
raise ValueError(f"status_code_str must be passed for {status_code!r}")
128+
129+
return status_code_str
130+
131+
83132
def response_from_data( # noqa: PLR0911
84133
*,
85-
status_code: HTTPStatus,
134+
status_code_str: str | None = None,
135+
status_code: HTTPStatusSpec,
86136
data: Union[oai.Response, oai.Reference],
87137
schemas: Schemas,
88138
responses: dict[str, Union[oai.Response, oai.Reference]],
89139
parent_name: str,
90140
config: Config,
91141
) -> tuple[Union[Response, ParseError], Schemas]:
92142
"""Generate a Response from the OpenAPI dictionary representation of it"""
143+
status_code_str = _status_code_str(status_code_str, status_code)
93144

94-
response_name = f"response_{status_code}"
145+
response_name = f"response_{status_code_str}"
95146
if isinstance(data, oai.Reference):
96147
ref_path = parse_reference_path(data.ref)
97148
if isinstance(ref_path, ParseError):

openapi_python_client/templates/endpoint_module.py.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ def _get_kwargs(
6767

6868
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[{{ return_string }}]:
6969
{% for response in endpoint.responses %}
70+
{% if response.status_code.value is defined %}
7071
if response.status_code == {{ response.status_code.value }}:
72+
{% else %}
73+
if {{ response.status_code[0].value }} <= response.status_code <= {{ response.status_code[1] }}:
74+
{% endif %}
7175
{% if parsed_responses %}{% import "property_templates/" + response.prop.template as prop_template %}
7276
{% if prop_template.construct %}
7377
{{ prop_template.construct(response.prop, response.source.attribute) | indent(8) }}

tests/test_parser/test_openapi.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from http import HTTPStatus
12
from unittest.mock import MagicMock
23

34
import pydantic
@@ -7,6 +8,7 @@
78
from openapi_python_client.parser.errors import ParseError
89
from openapi_python_client.parser.openapi import Endpoint, EndpointCollection, import_string_from_class
910
from openapi_python_client.parser.properties import Class, IntProperty, Parameters, Schemas
11+
from openapi_python_client.parser.responses import empty_response
1012
from openapi_python_client.schema import DataType
1113

1214
MODULE_NAME = "openapi_python_client.parser.openapi"
@@ -48,6 +50,44 @@ def test__add_responses_status_code_error(self, response_status_code, mocker):
4850
]
4951
response_from_data.assert_not_called()
5052

53+
def test__add_response_with_patterned_status_code(self, mocker):
54+
schemas = Schemas()
55+
response_1_data = mocker.MagicMock()
56+
data = {
57+
"2XX": response_1_data,
58+
}
59+
endpoint = self.make_endpoint()
60+
config = MagicMock()
61+
response = empty_response(
62+
status_code=(HTTPStatus(200), 299),
63+
response_name="dummy",
64+
config=config,
65+
data=data,
66+
)
67+
response_from_data = mocker.patch(f"{MODULE_NAME}.response_from_data", return_value=(response, schemas))
68+
69+
response, schemas = Endpoint._add_responses(
70+
endpoint=endpoint, data=data, schemas=schemas, responses={}, config=config
71+
)
72+
73+
assert response.errors == []
74+
75+
assert response.responses[0].status_code == (200, 299)
76+
77+
response_from_data.assert_has_calls(
78+
[
79+
mocker.call(
80+
status_code_str="2XX",
81+
status_code=(HTTPStatus(200), 299),
82+
data=response_1_data,
83+
schemas=schemas,
84+
responses={},
85+
parent_name="name",
86+
config=config,
87+
),
88+
]
89+
)
90+
5191
def test__add_responses_error(self, mocker):
5292
schemas = Schemas()
5393
response_1_data = mocker.MagicMock()
@@ -68,6 +108,7 @@ def test__add_responses_error(self, mocker):
68108
response_from_data.assert_has_calls(
69109
[
70110
mocker.call(
111+
status_code_str="200",
71112
status_code=200,
72113
data=response_1_data,
73114
schemas=schemas,
@@ -76,6 +117,7 @@ def test__add_responses_error(self, mocker):
76117
config=config,
77118
),
78119
mocker.call(
120+
status_code_str="404",
79121
status_code=404,
80122
data=response_2_data,
81123
schemas=schemas,

tests/test_parser/test_responses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ def test_response_from_data_content_type_overrides(any_property_factory):
240240
config = MagicMock()
241241
config.content_type_overrides = {"application/zip": "application/octet-stream"}
242242
response, schemas = response_from_data(
243+
status_code_str="200",
243244
status_code=200,
244245
data=data,
245246
schemas=Schemas(),

0 commit comments

Comments
 (0)