Skip to content

Commit 23b6caf

Browse files
authored
fix: support $ref from endpoint response to components/responses BNCH-109662 (#213)
* fix: support $ref from endpoint response to components/responses * lint * lint * test coverage * test coverage * fix test to test the right thing * add a helper
1 parent a0b1bb7 commit 23b6caf

File tree

10 files changed

+362
-48
lines changed

10 files changed

+362
-48
lines changed

end_to_end_tests/baseline_openapi_3.0.json

+26
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,20 @@
991991
}
992992
}
993993
},
994+
"/responses/reference": {
995+
"get": {
996+
"tags": [
997+
"responses"
998+
],
999+
"summary": "Endpoint using predefined response",
1000+
"operationId": "reference_response",
1001+
"responses": {
1002+
"200": {
1003+
"$ref": "#/components/responses/AResponse"
1004+
}
1005+
}
1006+
}
1007+
},
9941008
"/auth/token_with_cookie": {
9951009
"get": {
9961010
"tags": [
@@ -2930,6 +2944,18 @@
29302944
}
29312945
}
29322946
}
2947+
},
2948+
"responses": {
2949+
"AResponse": {
2950+
"description": "OK",
2951+
"content": {
2952+
"application/json": {
2953+
"schema": {
2954+
"$ref": "#/components/schemas/AModel"
2955+
}
2956+
}
2957+
}
2958+
}
29332959
}
29342960
}
29352961
}

end_to_end_tests/baseline_openapi_3.1.yaml

+21
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,20 @@ info:
983983
}
984984
}
985985
},
986+
"/responses/reference": {
987+
"get": {
988+
"tags": [
989+
"responses"
990+
],
991+
"summary": "Endpoint using predefined response",
992+
"operationId": "reference_response",
993+
"responses": {
994+
"200": {
995+
"$ref": "#/components/responses/AResponse"
996+
}
997+
}
998+
}
999+
},
9861000
"/auth/token_with_cookie": {
9871001
"get": {
9881002
"tags": [
@@ -2921,3 +2935,10 @@ info:
29212935
"application/json":
29222936
"schema":
29232937
"$ref": "#/components/schemas/AModel"
2938+
responses:
2939+
AResponse:
2940+
description: OK
2941+
content:
2942+
"application/json":
2943+
"schema":
2944+
"$ref": "#/components/schemas/AModel"

end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/responses/__init__.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import types
44

5-
from . import post_responses_unions_simple_before_complex, text_response
5+
from . import post_responses_unions_simple_before_complex, reference_response, text_response
66

77

88
class ResponsesEndpoints:
@@ -19,3 +19,10 @@ def text_response(cls) -> types.ModuleType:
1919
Text Response
2020
"""
2121
return text_response
22+
23+
@classmethod
24+
def reference_response(cls) -> types.ModuleType:
25+
"""
26+
Endpoint using predefined response
27+
"""
28+
return reference_response
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from http import HTTPStatus
2+
from typing import Any, Dict, Optional, Union
3+
4+
import httpx
5+
6+
from ... import errors
7+
from ...client import AuthenticatedClient, Client
8+
from ...models.a_model import AModel
9+
from ...types import Response
10+
11+
12+
def _get_kwargs() -> Dict[str, Any]:
13+
_kwargs: Dict[str, Any] = {
14+
"method": "get",
15+
"url": "/responses/reference",
16+
}
17+
18+
return _kwargs
19+
20+
21+
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[AModel]:
22+
if response.status_code == 200:
23+
response_200 = AModel.from_dict(response.json())
24+
25+
return response_200
26+
if client.raise_on_unexpected_status:
27+
raise errors.UnexpectedStatus(response.status_code, response.content)
28+
else:
29+
return None
30+
31+
32+
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[AModel]:
33+
return Response(
34+
status_code=HTTPStatus(response.status_code),
35+
content=response.content,
36+
headers=response.headers,
37+
parsed=_parse_response(client=client, response=response),
38+
)
39+
40+
41+
def sync_detailed(
42+
*,
43+
client: Union[AuthenticatedClient, Client],
44+
) -> Response[AModel]:
45+
"""Endpoint using predefined response
46+
47+
Raises:
48+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
49+
httpx.TimeoutException: If the request takes longer than Client.timeout.
50+
51+
Returns:
52+
Response[AModel]
53+
"""
54+
55+
kwargs = _get_kwargs()
56+
57+
response = client.get_httpx_client().request(
58+
**kwargs,
59+
)
60+
61+
return _build_response(client=client, response=response)
62+
63+
64+
def sync(
65+
*,
66+
client: Union[AuthenticatedClient, Client],
67+
) -> Optional[AModel]:
68+
"""Endpoint using predefined response
69+
70+
Raises:
71+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
72+
httpx.TimeoutException: If the request takes longer than Client.timeout.
73+
74+
Returns:
75+
AModel
76+
"""
77+
78+
return sync_detailed(
79+
client=client,
80+
).parsed
81+
82+
83+
async def asyncio_detailed(
84+
*,
85+
client: Union[AuthenticatedClient, Client],
86+
) -> Response[AModel]:
87+
"""Endpoint using predefined response
88+
89+
Raises:
90+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
91+
httpx.TimeoutException: If the request takes longer than Client.timeout.
92+
93+
Returns:
94+
Response[AModel]
95+
"""
96+
97+
kwargs = _get_kwargs()
98+
99+
response = await client.get_async_httpx_client().request(**kwargs)
100+
101+
return _build_response(client=client, response=response)
102+
103+
104+
async def asyncio(
105+
*,
106+
client: Union[AuthenticatedClient, Client],
107+
) -> Optional[AModel]:
108+
"""Endpoint using predefined response
109+
110+
Raises:
111+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
112+
httpx.TimeoutException: If the request takes longer than Client.timeout.
113+
114+
Returns:
115+
AModel
116+
"""
117+
118+
return (
119+
await asyncio_detailed(
120+
client=client,
121+
)
122+
).parsed

openapi_python_client/parser/bodies.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
Schemas,
1010
property_from_data,
1111
)
12+
from openapi_python_client.parser.properties.schemas import get_reference_simple_name
1213

1314
from .. import schema as oai
1415
from ..config import Config
@@ -138,7 +139,7 @@ def _resolve_reference(
138139
references_seen = []
139140
while isinstance(body, oai.Reference) and body.ref not in references_seen:
140141
references_seen.append(body.ref)
141-
body = request_bodies.get(body.ref.split("/")[-1])
142+
body = request_bodies.get(get_reference_simple_name(body.ref))
142143
if isinstance(body, oai.Reference):
143144
return ParseError(detail="Circular $ref in request body", data=body)
144145
if body is None and references_seen:

openapi_python_client/parser/openapi.py

+24-3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def from_data(
5050
schemas: Schemas,
5151
parameters: Parameters,
5252
request_bodies: Dict[str, Union[oai.RequestBody, oai.Reference]],
53+
responses: Dict[str, Union[oai.Response, oai.Reference]],
5354
config: Config,
5455
) -> Tuple[Dict[utils.PythonIdentifier, "EndpointCollection"], Schemas, Parameters]:
5556
"""Parse the openapi paths data to get EndpointCollections by tag"""
@@ -72,6 +73,7 @@ def from_data(
7273
schemas=schemas,
7374
parameters=parameters,
7475
request_bodies=request_bodies,
76+
responses=responses,
7577
config=config,
7678
)
7779
# Add `PathItem` parameters
@@ -144,7 +146,12 @@ class Endpoint:
144146

145147
@staticmethod
146148
def _add_responses(
147-
*, endpoint: "Endpoint", data: oai.Responses, schemas: Schemas, config: Config
149+
*,
150+
endpoint: "Endpoint",
151+
data: oai.Responses,
152+
schemas: Schemas,
153+
responses: Dict[str, Union[oai.Response, oai.Reference]],
154+
config: Config,
148155
) -> Tuple["Endpoint", Schemas]:
149156
endpoint = deepcopy(endpoint)
150157
for code, response_data in data.items():
@@ -167,6 +174,7 @@ def _add_responses(
167174
status_code=status_code,
168175
data=response_data,
169176
schemas=schemas,
177+
responses=responses,
170178
parent_name=endpoint.name,
171179
config=config,
172180
)
@@ -396,6 +404,7 @@ def from_data(
396404
schemas: Schemas,
397405
parameters: Parameters,
398406
request_bodies: Dict[str, Union[oai.RequestBody, oai.Reference]],
407+
responses: Dict[str, Union[oai.Response, oai.Reference]],
399408
config: Config,
400409
) -> Tuple[Union["Endpoint", ParseError], Schemas, Parameters]:
401410
"""Construct an endpoint from the OpenAPI data"""
@@ -424,7 +433,13 @@ def from_data(
424433
)
425434
if isinstance(result, ParseError):
426435
return result, schemas, parameters
427-
result, schemas = Endpoint._add_responses(endpoint=result, data=data.responses, schemas=schemas, config=config)
436+
result, schemas = Endpoint._add_responses(
437+
endpoint=result,
438+
data=data.responses,
439+
schemas=schemas,
440+
responses=responses,
441+
config=config,
442+
)
428443
if isinstance(result, ParseError):
429444
return result, schemas, parameters
430445
bodies, schemas = body_from_data(
@@ -514,8 +529,14 @@ def from_dict(data: Dict[str, Any], *, config: Config) -> Union["GeneratorData",
514529
config=config,
515530
)
516531
request_bodies = (openapi.components and openapi.components.requestBodies) or {}
532+
responses = (openapi.components and openapi.components.responses) or {}
517533
endpoint_collections_by_tag, schemas, parameters = EndpointCollection.from_data(
518-
data=openapi.paths, schemas=schemas, parameters=parameters, request_bodies=request_bodies, config=config
534+
data=openapi.paths,
535+
schemas=schemas,
536+
parameters=parameters,
537+
request_bodies=request_bodies,
538+
responses=responses,
539+
config=config,
519540
)
520541

521542
enums = (

openapi_python_client/parser/properties/schemas.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ def parse_reference_path(ref_path_raw: str) -> Union[ReferencePath, ParseError]:
4646
return cast(ReferencePath, parsed.fragment)
4747

4848

49+
def get_reference_simple_name(ref_path: str) -> str:
50+
"""
51+
Takes a path like `/components/schemas/NameOfThing` and returns a string like `NameOfThing`.
52+
"""
53+
return ref_path.split("/", 3)[-1]
54+
55+
4956
@define
5057
class Class:
5158
"""Represents Python class which will be generated from an OpenAPI schema"""
@@ -56,7 +63,7 @@ class Class:
5663
@staticmethod
5764
def from_string(*, string: str, config: Config) -> "Class":
5865
"""Get a Class from an arbitrary string"""
59-
class_name = string.split("/")[-1] # Get rid of ref path stuff
66+
class_name = get_reference_simple_name(string) # Get rid of ref path stuff
6067
class_name = ClassName(class_name, config.field_prefix)
6168
override = config.class_overrides.get(class_name)
6269

openapi_python_client/parser/responses.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
__all__ = ["Response", "response_from_data"]
22

33
from http import HTTPStatus
4-
from typing import Optional, Tuple, TypedDict, Union
4+
from typing import Dict, Optional, Tuple, TypedDict, Union
55

66
from attrs import define
77

88
from openapi_python_client import utils
9+
from openapi_python_client.parser.properties.schemas import get_reference_simple_name, parse_reference_path
910

1011
from .. import Config
1112
from .. import schema as oai
@@ -79,27 +80,30 @@ def empty_response(
7980
)
8081

8182

82-
def response_from_data(
83+
def response_from_data( # noqa: PLR0911
8384
*,
8485
status_code: HTTPStatus,
8586
data: Union[oai.Response, oai.Reference],
8687
schemas: Schemas,
88+
responses: Dict[str, Union[oai.Response, oai.Reference]],
8789
parent_name: str,
8890
config: Config,
8991
) -> Tuple[Union[Response, ParseError], Schemas]:
9092
"""Generate a Response from the OpenAPI dictionary representation of it"""
9193

9294
response_name = f"response_{status_code}"
9395
if isinstance(data, oai.Reference):
94-
return (
95-
empty_response(
96-
status_code=status_code,
97-
response_name=response_name,
98-
config=config,
99-
data=data,
100-
),
101-
schemas,
102-
)
96+
ref_path = parse_reference_path(data.ref)
97+
if isinstance(ref_path, ParseError):
98+
return ref_path, schemas
99+
if not ref_path.startswith("/components/responses/"):
100+
return ParseError(data=data, detail=f"$ref to {data.ref} not allowed in responses"), schemas
101+
resp_data = responses.get(get_reference_simple_name(ref_path), None)
102+
if not resp_data:
103+
return ParseError(data=data, detail=f"Could not find reference: {data.ref}"), schemas
104+
if not isinstance(resp_data, oai.Response):
105+
return ParseError(data=data, detail="Top-level $ref inside components/responses is not supported"), schemas
106+
data = resp_data
103107

104108
content = data.content
105109
if not content:

0 commit comments

Comments
 (0)