Skip to content

Commit ac8ffef

Browse files
dbantyeli-bl
authored andcommitted
Support $ref in responses (openapi-generators#1207)
Updates and closes openapi-generators#1148 --------- Co-authored-by: Eli Bishop <[email protected]> Co-authored-by: Dylan Anthony <[email protected]>
1 parent f1ba4cf commit ac8ffef

File tree

11 files changed

+371
-46
lines changed

11 files changed

+371
-46
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
default: major
3+
---
4+
5+
# Support `$ref` in responses
6+
7+
Previously, using a `$ref` to define a response was ignored, the code to call the endpoint was still generated, but
8+
the response would not be parsed. Now, responses defined with `$ref` will be used to generate the response model, which
9+
will parse the response at runtime.
10+
11+
If a `$ref` is incorrect or uses a feature that is not supported by the generator, these endpoints will start failing to
12+
generate.

end_to_end_tests/baseline_openapi_3.0.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,20 @@
10421042
}
10431043
}
10441044
},
1045+
"/responses/reference": {
1046+
"get": {
1047+
"tags": [
1048+
"responses"
1049+
],
1050+
"summary": "Endpoint using predefined response",
1051+
"operationId": "reference_response",
1052+
"responses": {
1053+
"200": {
1054+
"$ref": "#/components/responses/AResponse"
1055+
}
1056+
}
1057+
}
1058+
},
10451059
"/auth/token_with_cookie": {
10461060
"get": {
10471061
"tags": [
@@ -3020,6 +3034,18 @@
30203034
}
30213035
}
30223036
}
3037+
},
3038+
"responses": {
3039+
"AResponse": {
3040+
"description": "OK",
3041+
"content": {
3042+
"application/json": {
3043+
"schema": {
3044+
"$ref": "#/components/schemas/AModel"
3045+
}
3046+
}
3047+
}
3048+
}
30233049
}
30243050
}
30253051
}

end_to_end_tests/baseline_openapi_3.1.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,20 @@ info:
10341034
}
10351035
}
10361036
},
1037+
"/responses/reference": {
1038+
"get": {
1039+
"tags": [
1040+
"responses"
1041+
],
1042+
"summary": "Endpoint using predefined response",
1043+
"operationId": "reference_response",
1044+
"responses": {
1045+
"200": {
1046+
"$ref": "#/components/responses/AResponse"
1047+
}
1048+
}
1049+
}
1050+
},
10371051
"/auth/token_with_cookie": {
10381052
"get": {
10391053
"tags": [
@@ -3011,3 +3025,10 @@ info:
30113025
"application/json":
30123026
"schema":
30133027
"$ref": "#/components/schemas/AModel"
3028+
responses:
3029+
AResponse:
3030+
description: OK
3031+
content:
3032+
"application/json":
3033+
"schema":
3034+
"$ref": "#/components/schemas/AModel"

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

Lines changed: 8 additions & 1 deletion
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
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from http import HTTPStatus
2+
from typing import Any, 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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def from_data(
5151
schemas: Schemas,
5252
parameters: Parameters,
5353
request_bodies: dict[str, Union[oai.RequestBody, oai.Reference]],
54+
responses: dict[str, Union[oai.Response, oai.Reference]],
5455
config: Config,
5556
) -> tuple[dict[utils.PythonIdentifier, "EndpointCollection"], Schemas, Parameters]:
5657
"""Parse the openapi paths data to get EndpointCollections by tag"""
@@ -78,6 +79,7 @@ def from_data(
7879
schemas=schemas,
7980
parameters=parameters,
8081
request_bodies=request_bodies,
82+
responses=responses,
8183
config=config,
8284
)
8385
# Add `PathItem` parameters
@@ -151,7 +153,12 @@ class Endpoint:
151153

152154
@staticmethod
153155
def _add_responses(
154-
*, endpoint: "Endpoint", data: oai.Responses, schemas: Schemas, config: Config
156+
*,
157+
endpoint: "Endpoint",
158+
data: oai.Responses,
159+
schemas: Schemas,
160+
responses: dict[str, Union[oai.Response, oai.Reference]],
161+
config: Config,
155162
) -> tuple["Endpoint", Schemas]:
156163
endpoint = deepcopy(endpoint)
157164
for code, response_data in data.items():
@@ -174,6 +181,7 @@ def _add_responses(
174181
status_code=status_code,
175182
data=response_data,
176183
schemas=schemas,
184+
responses=responses,
177185
parent_name=endpoint.name,
178186
config=config,
179187
)
@@ -403,6 +411,7 @@ def from_data(
403411
schemas: Schemas,
404412
parameters: Parameters,
405413
request_bodies: dict[str, Union[oai.RequestBody, oai.Reference]],
414+
responses: dict[str, Union[oai.Response, oai.Reference]],
406415
config: Config,
407416
) -> tuple[Union["Endpoint", ParseError], Schemas, Parameters]:
408417
"""Construct an endpoint from the OpenAPI data"""
@@ -431,7 +440,13 @@ def from_data(
431440
)
432441
if isinstance(result, ParseError):
433442
return result, schemas, parameters
434-
result, schemas = Endpoint._add_responses(endpoint=result, data=data.responses, schemas=schemas, config=config)
443+
result, schemas = Endpoint._add_responses(
444+
endpoint=result,
445+
data=data.responses,
446+
schemas=schemas,
447+
responses=responses,
448+
config=config,
449+
)
435450
if isinstance(result, ParseError):
436451
return result, schemas, parameters
437452
bodies, schemas = body_from_data(
@@ -521,8 +536,14 @@ def from_dict(data: dict[str, Any], *, config: Config) -> Union["GeneratorData",
521536
config=config,
522537
)
523538
request_bodies = (openapi.components and openapi.components.requestBodies) or {}
539+
responses = (openapi.components and openapi.components.responses) or {}
524540
endpoint_collections_by_tag, schemas, parameters = EndpointCollection.from_data(
525-
data=openapi.paths, schemas=schemas, parameters=parameters, request_bodies=request_bodies, config=config
541+
data=openapi.paths,
542+
schemas=schemas,
543+
parameters=parameters,
544+
request_bodies=request_bodies,
545+
responses=responses,
546+
config=config,
526547
)
527548

528549
enums = (

openapi_python_client/parser/properties/schemas.py

Lines changed: 8 additions & 1 deletion
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("/")[-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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
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)