Skip to content

Commit 3df0ba6

Browse files
obs-gh-peterkollochdbanty
authored andcommitted
feat(parser): #1271 Support simple patterned http status codes
1 parent 8f5f11f commit 3df0ba6

File tree

6 files changed

+120
-115
lines changed

6 files changed

+120
-115
lines changed

end_to_end_tests/__snapshots__/test_end_to_end.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
WARNING parsing GET / within default.
88

9-
Invalid response status code abcdef (not a valid HTTP status code), response will be omitted from generated client
9+
Invalid response status code pattern: abcdef, response will be omitted from generated client
1010

1111

1212
If you believe this was a mistake or this tool is missing a feature you need, please open an issue at https://github.com/openapi-generators/openapi-python-client/issues/new/choose

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 HTTPStatusPattern, Response, response_from_data
3029

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

@@ -162,19 +161,9 @@ 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 = HTTPStatusPattern.parse(code)
165+
if isinstance(status_code, ParseError):
166+
endpoint.errors.append(status_code)
178167
continue
179168

180169
response, schemas = response_from_data(
@@ -190,7 +179,7 @@ def _add_responses(
190179
endpoint.errors.append(
191180
ParseError(
192181
detail=(
193-
f"Cannot parse response for status code {status_code}{detail_suffix}, "
182+
f"Cannot parse response for status code {code}{detail_suffix}, "
194183
f"response will be omitted from generated client"
195184
),
196185
data=response.data,
@@ -202,6 +191,7 @@ def _add_responses(
202191
endpoint.relative_imports |= response.prop.get_lazy_imports(prefix=models_relative_prefix)
203192
endpoint.relative_imports |= response.prop.get_imports(prefix=models_relative_prefix)
204193
endpoint.responses.append(response)
194+
endpoint.responses.sort()
205195
return endpoint, schemas
206196

207197
@staticmethod

openapi_python_client/parser/responses.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
__all__ = ["Response", "response_from_data"]
1+
__all__ = ["HTTPStatusPattern", "Response", "response_from_data"]
22

3-
from http import HTTPStatus
43
from typing import Optional, TypedDict, Union
54

65
from attrs import define
@@ -28,15 +27,85 @@ class _ResponseSource(TypedDict):
2827
NONE_SOURCE = _ResponseSource(attribute="None", return_type="None")
2928

3029

31-
@define
30+
class HTTPStatusPattern:
31+
"""Status code patterns come in three flavors, in order of precedence:
32+
1. Specific status codes, such as 200. This is represented by `min` and `max` being the same.
33+
2. Ranges of status codes, such as 2XX. This is represented by `min` and `max` being different.
34+
3. The special `default` status code, which is used when no other status codes match. `range` is `None` in this case.
35+
36+
https://github.com/openapi-generators/openapi-python-client/blob/61b6c54994e2a6285bb422ee3b864c45b5d88c15/openapi_python_client/schema/3.1.0.md#responses-object
37+
"""
38+
39+
pattern: str
40+
range: Optional[tuple[int, int]]
41+
42+
def __init__(self, *, pattern: str, code_range: Optional[tuple[int, int]]):
43+
"""Initialize with a range of status codes or None for the default case."""
44+
self.pattern = pattern
45+
self.range = code_range
46+
47+
@staticmethod
48+
def parse(pattern: str) -> Union["HTTPStatusPattern", ParseError]:
49+
"""Parse a status code pattern such as 2XX or 404"""
50+
if pattern == "default":
51+
return HTTPStatusPattern(pattern=pattern, code_range=None)
52+
53+
if pattern.endswith("XX") and pattern[0].isdigit():
54+
first_digit = int(pattern[0])
55+
return HTTPStatusPattern(pattern=pattern, code_range=(first_digit * 100, first_digit * 100 + 99))
56+
57+
try:
58+
code = int(pattern)
59+
return HTTPStatusPattern(pattern=pattern, code_range=(code, code))
60+
except ValueError:
61+
return ParseError(
62+
detail=(
63+
f"Invalid response status code pattern: {pattern}, response will be omitted from generated client"
64+
)
65+
)
66+
67+
def is_range(self) -> bool:
68+
"""Check if this is a range of status codes, such as 2XX"""
69+
return self.range is not None and self.range[0] != self.range[1]
70+
71+
def __lt__(self, other: "HTTPStatusPattern") -> bool:
72+
"""Compare two HTTPStatusPattern objects based on the order they should be applied in"""
73+
if self.range is None:
74+
return False # Default gets applied last
75+
if other.range is None:
76+
return True # Other is default, so this one gets applied first
77+
78+
# Specific codes appear before ranges
79+
if self.is_range() and not other.is_range():
80+
return False
81+
if not self.is_range() and other.is_range():
82+
return True
83+
84+
# Order specific codes numerically
85+
return self.range[0] < other.range[0]
86+
87+
def __eq__(self, other: object) -> bool:
88+
if not isinstance(other, HTTPStatusPattern):
89+
return False
90+
return self.range == other.range
91+
92+
def __hash__(self) -> int:
93+
return hash(self.range)
94+
95+
96+
@define(order=False)
3297
class Response:
3398
"""Describes a single response for an endpoint"""
3499

35-
status_code: HTTPStatus
100+
status_code: HTTPStatusPattern
36101
prop: Property
37102
source: _ResponseSource
38103
data: Union[oai.Response, oai.Reference] # Original data which created this response, useful for custom templates
39104

105+
def __lt__(self, other: "Response") -> bool:
106+
"""Compare two responses based on the order in which they should be applied in"""
107+
return self.status_code < other.status_code
108+
40109

41110
def _source_by_content_type(content_type: str, config: Config) -> Optional[_ResponseSource]:
42111
parsed_content_type = utils.get_content_type(content_type, config)
@@ -59,7 +128,7 @@ def _source_by_content_type(content_type: str, config: Config) -> Optional[_Resp
59128

60129
def empty_response(
61130
*,
62-
status_code: HTTPStatus,
131+
status_code: HTTPStatusPattern,
63132
response_name: str,
64133
config: Config,
65134
data: Union[oai.Response, oai.Reference],
@@ -82,7 +151,7 @@ def empty_response(
82151

83152
def response_from_data( # noqa: PLR0911
84153
*,
85-
status_code: HTTPStatus,
154+
status_code: HTTPStatusPattern,
86155
data: Union[oai.Response, oai.Reference],
87156
schemas: Schemas,
88157
responses: dict[str, Union[oai.Response, oai.Reference]],
@@ -91,7 +160,7 @@ def response_from_data( # noqa: PLR0911
91160
) -> tuple[Union[Response, ParseError], Schemas]:
92161
"""Generate a Response from the OpenAPI dictionary representation of it"""
93162

94-
response_name = f"response_{status_code}"
163+
response_name = f"response_{status_code.pattern}"
95164
if isinstance(data, oai.Reference):
96165
ref_path = parse_reference_path(data.ref)
97166
if isinstance(ref_path, ParseError):

openapi_python_client/templates/endpoint_module.py.jinja

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,16 @@ def _get_kwargs(
6666

6767

6868
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[{{ return_string }}]:
69+
{% set has_default = False %}
6970
{% for response in endpoint.responses %}
70-
if response.status_code == {{ response.status_code.value }}:
71+
{% set code_range = response.status_code.range %}
72+
{% if code_range == None %} {% set has_default = True %}
73+
else:
74+
{% elif code_range[0] == code_range[1] %}
75+
if response.status_code == {{ code_range[0] }}:
76+
{% else %}
77+
if {{ code_range[0] }} <= response.status_code <= {{ code_range[1] }}:
78+
{% endif %}
7179
{% if parsed_responses %}{% import "property_templates/" + response.prop.template as prop_template %}
7280
{% if prop_template.construct %}
7381
{{ prop_template.construct(response.prop, response.source.attribute) | indent(8) }}
@@ -81,10 +89,12 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
8189
return None
8290
{% endif %}
8391
{% endfor %}
92+
{% if not has_default %}
8493
if client.raise_on_unexpected_status:
8594
raise errors.UnexpectedStatus(response.status_code, response.content)
8695
else:
8796
return None
97+
{% endif %}
8898

8999

90100
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[{{ return_string }}]:

tests/test_parser/test_openapi.py

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -24,80 +24,6 @@ def make_endpoint(self):
2424
relative_imports={"import_3"},
2525
)
2626

27-
@pytest.mark.parametrize("response_status_code", ["not_a_number", 499])
28-
def test__add_responses_status_code_error(self, response_status_code, mocker):
29-
schemas = Schemas()
30-
response_1_data = mocker.MagicMock()
31-
data = {
32-
response_status_code: response_1_data,
33-
}
34-
endpoint = self.make_endpoint()
35-
parse_error = ParseError(data=mocker.MagicMock())
36-
response_from_data = mocker.patch(f"{MODULE_NAME}.response_from_data", return_value=(parse_error, schemas))
37-
config = MagicMock()
38-
39-
response, schemas = Endpoint._add_responses(
40-
endpoint=endpoint, data=data, schemas=schemas, responses={}, config=config
41-
)
42-
43-
assert response.errors == [
44-
ParseError(
45-
detail=f"Invalid response status code {response_status_code} (not a valid HTTP status code), "
46-
"response will be omitted from generated client"
47-
)
48-
]
49-
response_from_data.assert_not_called()
50-
51-
def test__add_responses_error(self, mocker):
52-
schemas = Schemas()
53-
response_1_data = mocker.MagicMock()
54-
response_2_data = mocker.MagicMock()
55-
data = {
56-
"200": response_1_data,
57-
"404": response_2_data,
58-
}
59-
endpoint = self.make_endpoint()
60-
parse_error = ParseError(data=mocker.MagicMock(), detail="some problem")
61-
response_from_data = mocker.patch(f"{MODULE_NAME}.response_from_data", return_value=(parse_error, schemas))
62-
config = MagicMock()
63-
64-
response, schemas = Endpoint._add_responses(
65-
endpoint=endpoint, data=data, schemas=schemas, responses={}, config=config
66-
)
67-
68-
response_from_data.assert_has_calls(
69-
[
70-
mocker.call(
71-
status_code=200,
72-
data=response_1_data,
73-
schemas=schemas,
74-
responses={},
75-
parent_name="name",
76-
config=config,
77-
),
78-
mocker.call(
79-
status_code=404,
80-
data=response_2_data,
81-
schemas=schemas,
82-
responses={},
83-
parent_name="name",
84-
config=config,
85-
),
86-
]
87-
)
88-
assert response.errors == [
89-
ParseError(
90-
detail="Cannot parse response for status code 200 (some problem), "
91-
"response will be omitted from generated client",
92-
data=parse_error.data,
93-
),
94-
ParseError(
95-
detail="Cannot parse response for status code 404 (some problem), "
96-
"response will be omitted from generated client",
97-
data=parse_error.data,
98-
),
99-
]
100-
10127
def test_add_parameters_handles_no_params(self):
10228
endpoint = self.make_endpoint()
10329
schemas = Schemas()

0 commit comments

Comments
 (0)