Skip to content

Commit 888d881

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

File tree

6 files changed

+101
-108
lines changed

6 files changed

+101
-108
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: 68 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,77 @@ 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+
88+
@define(order=False)
3289
class Response:
3390
"""Describes a single response for an endpoint"""
3491

35-
status_code: HTTPStatus
92+
status_code: HTTPStatusPattern
3693
prop: Property
3794
source: _ResponseSource
3895
data: Union[oai.Response, oai.Reference] # Original data which created this response, useful for custom templates
3996

97+
def __lt__(self, other: "Response") -> bool:
98+
"""Compare two responses based on the order in which they should be applied in"""
99+
return self.status_code < other.status_code
100+
40101

41102
def _source_by_content_type(content_type: str, config: Config) -> Optional[_ResponseSource]:
42103
parsed_content_type = utils.get_content_type(content_type, config)
@@ -59,7 +120,7 @@ def _source_by_content_type(content_type: str, config: Config) -> Optional[_Resp
59120

60121
def empty_response(
61122
*,
62-
status_code: HTTPStatus,
123+
status_code: HTTPStatusPattern,
63124
response_name: str,
64125
config: Config,
65126
data: Union[oai.Response, oai.Reference],
@@ -82,7 +143,7 @@ def empty_response(
82143

83144
def response_from_data( # noqa: PLR0911
84145
*,
85-
status_code: HTTPStatus,
146+
status_code: HTTPStatusPattern,
86147
data: Union[oai.Response, oai.Reference],
87148
schemas: Schemas,
88149
responses: dict[str, Union[oai.Response, oai.Reference]],
@@ -91,7 +152,7 @@ def response_from_data( # noqa: PLR0911
91152
) -> tuple[Union[Response, ParseError], Schemas]:
92153
"""Generate a Response from the OpenAPI dictionary representation of it"""
93154

94-
response_name = f"response_{status_code}"
155+
response_name = f"response_{status_code.pattern}"
95156
if isinstance(data, oai.Reference):
96157
ref_path = parse_reference_path(data.ref)
97158
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()

tests/test_parser/test_responses.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from openapi_python_client.parser import responses
77
from openapi_python_client.parser.errors import ParseError, PropertyError
88
from openapi_python_client.parser.properties import Schemas
9-
from openapi_python_client.parser.responses import JSON_SOURCE, NONE_SOURCE, Response, response_from_data
9+
from openapi_python_client.parser.responses import (
10+
JSON_SOURCE,
11+
NONE_SOURCE,
12+
HTTPStatusPattern,
13+
Response,
14+
response_from_data,
15+
)
1016

1117
MODULE_NAME = "openapi_python_client.parser.responses"
1218

@@ -15,7 +21,7 @@ def test_response_from_data_no_content(any_property_factory):
1521
data = oai.Response.model_construct(description="")
1622

1723
response, schemas = response_from_data(
18-
status_code=200,
24+
status_code=HTTPStatusPattern.parse("200"),
1925
data=data,
2026
schemas=Schemas(),
2127
responses={},
@@ -41,7 +47,7 @@ def test_response_from_data_unsupported_content_type():
4147
config = MagicMock()
4248
config.content_type_overrides = {}
4349
response, schemas = response_from_data(
44-
status_code=200,
50+
status_code=HTTPStatusPattern.parse("200"),
4551
data=data,
4652
schemas=Schemas(),
4753
responses={},
@@ -60,7 +66,7 @@ def test_response_from_data_no_content_schema(any_property_factory):
6066
config = MagicMock()
6167
config.content_type_overrides = {}
6268
response, schemas = response_from_data(
63-
status_code=200,
69+
status_code=HTTPStatusPattern.parse("200"),
6470
data=data,
6571
schemas=Schemas(),
6672
responses={},
@@ -121,7 +127,7 @@ def test_response_from_data_property(mocker, any_property_factory):
121127
config.content_type_overrides = {}
122128

123129
response, schemas = responses.response_from_data(
124-
status_code=400,
130+
status_code=HTTPStatusPattern.parse("400"),
125131
data=data,
126132
schemas=Schemas(),
127133
responses={},
@@ -156,7 +162,7 @@ def test_response_from_data_reference(mocker, any_property_factory):
156162
config.content_type_overrides = {}
157163

158164
response, schemas = responses.response_from_data(
159-
status_code=400,
165+
status_code=HTTPStatusPattern.parse("400"),
160166
data=oai.Reference.model_construct(ref="#/components/responses/ErrorResponse"),
161167
schemas=Schemas(),
162168
responses={"ErrorResponse": predefined_response_data},
@@ -191,7 +197,7 @@ def test_response_from_data_invalid_reference(ref_string, expected_error_string,
191197
config.content_type_overrides = {}
192198

193199
response, schemas = responses.response_from_data(
194-
status_code=400,
200+
status_code=HTTPStatusPattern.parse("400"),
195201
data=oai.Reference.model_construct(ref=ref_string),
196202
schemas=Schemas(),
197203
responses={"ErrorResponse": predefined_response_data},
@@ -217,7 +223,7 @@ def test_response_from_data_ref_to_response_that_is_a_ref(mocker, any_property_f
217223
config.content_type_overrides = {}
218224

219225
response, schemas = responses.response_from_data(
220-
status_code=400,
226+
status_code=HTTPStatusPattern.parse("400"),
221227
data=oai.Reference.model_construct(ref="#/components/responses/ErrorResponse"),
222228
schemas=Schemas(),
223229
responses={
@@ -240,7 +246,7 @@ def test_response_from_data_content_type_overrides(any_property_factory):
240246
config = MagicMock()
241247
config.content_type_overrides = {"application/zip": "application/octet-stream"}
242248
response, schemas = response_from_data(
243-
status_code=200,
249+
status_code=HTTPStatusPattern.parse("200"),
244250
data=data,
245251
schemas=Schemas(),
246252
responses={},

0 commit comments

Comments
 (0)