Skip to content

Commit 6cf48dd

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

File tree

4 files changed

+86
-26
lines changed

4 files changed

+86
-26
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 & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
__all__ = ["Response", "response_from_data"]
1+
__all__ = ["Response", "response_from_data", "HTTPStatusPattern"]
22

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

65
from attrs import define
@@ -27,16 +26,77 @@ class _ResponseSource(TypedDict):
2726
TEXT_SOURCE = _ResponseSource(attribute="response.text", return_type="str")
2827
NONE_SOURCE = _ResponseSource(attribute="None", return_type="None")
2928

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

35-
status_code: HTTPStatus
91+
status_code: HTTPStatusPattern
3692
prop: Property
3793
source: _ResponseSource
3894
data: Union[oai.Response, oai.Reference] # Original data which created this response, useful for custom templates
3995

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

41101
def _source_by_content_type(content_type: str, config: Config) -> Optional[_ResponseSource]:
42102
parsed_content_type = utils.get_content_type(content_type, config)
@@ -59,7 +119,7 @@ def _source_by_content_type(content_type: str, config: Config) -> Optional[_Resp
59119

60120
def empty_response(
61121
*,
62-
status_code: HTTPStatus,
122+
status_code: HTTPStatusPattern,
63123
response_name: str,
64124
config: Config,
65125
data: Union[oai.Response, oai.Reference],
@@ -82,7 +142,7 @@ def empty_response(
82142

83143
def response_from_data( # noqa: PLR0911
84144
*,
85-
status_code: HTTPStatus,
145+
status_code: HTTPStatusPattern,
86146
data: Union[oai.Response, oai.Reference],
87147
schemas: Schemas,
88148
responses: dict[str, Union[oai.Response, oai.Reference]],
@@ -91,7 +151,7 @@ def response_from_data( # noqa: PLR0911
91151
) -> tuple[Union[Response, ParseError], Schemas]:
92152
"""Generate a Response from the OpenAPI dictionary representation of it"""
93153

94-
response_name = f"response_{status_code}"
154+
response_name = f"response_{status_code.pattern}"
95155
if isinstance(data, oai.Reference):
96156
ref_path = parse_reference_path(data.ref)
97157
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 }}]:

0 commit comments

Comments
 (0)