Skip to content

Commit 97f3908

Browse files
author
miggyst
authored
feat: Added support for text/yaml in OpenAPI to be used in benchling-api-client BNCH-37249 (#118)
* feat: Added support for text/yaml in OpenAPI to be used in benchling-api-client BNCH-37249 * Fixed all tests * Added yaml parse * Added yaml body to mimic json object * Added logic for proper parsing * renamed yaml to be a json object on return package * Updated test for yaml parsing changes * Uses ruamel.yaml instead of yaml for parsing * Updated tests
1 parent 5fe002c commit 97f3908

File tree

15 files changed

+290
-78
lines changed

15 files changed

+290
-78
lines changed

end_to_end_tests/golden-record-custom/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ include = ["CHANGELOG.md", "custom_e2e/py.typed"]
1414

1515
[tool.poetry.dependencies]
1616
python = "^3.6"
17-
httpx = "^0.15.0"
18-
attrs = "^20.1.0"
17+
httpx = ">=0.15.0, <=0.22.0"
18+
attrs = ">=20.1.0, <22.0"
1919
python-dateutil = "^2.8.0"
2020

2121
[tool.black]

end_to_end_tests/golden-record/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ include = ["CHANGELOG.md", "my_test_api_client/py.typed"]
1414

1515
[tool.poetry.dependencies]
1616
python = "^3.6"
17-
httpx = "^0.15.0"
18-
attrs = "^20.1.0"
17+
httpx = ">=0.15.0, <=0.22.0"
18+
attrs = ">=20.1.0, <22.0"
1919
python-dateutil = "^2.8.0"
2020

2121
[tool.black]

openapi_python_client/parser/openapi.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,29 @@ class Endpoint:
9494
path_parameters: List[Property] = field(default_factory=list)
9595
header_parameters: List[Property] = field(default_factory=list)
9696
responses: List[Response] = field(default_factory=list)
97+
yaml_body: Optional[Property] = None
9798
form_body_reference: Optional[Reference] = None
9899
json_body: Optional[Property] = None
99100
multipart_body_reference: Optional[Reference] = None
100101
errors: List[ParseError] = field(default_factory=list)
101102

103+
@staticmethod
104+
def parse_request_yaml_body(
105+
*, body: oai.RequestBody, schemas: Schemas, parent_name: str
106+
) -> Tuple[Union[Property, PropertyError, None], Schemas]:
107+
""" Return yaml_body """
108+
body_content = body.content
109+
yaml_body = body_content.get("text/yaml")
110+
if yaml_body is not None and yaml_body.media_type_schema is not None:
111+
return property_from_data(
112+
name="yaml_body",
113+
required=True,
114+
data=yaml_body.media_type_schema,
115+
schemas=schemas,
116+
parent_name=parent_name,
117+
)
118+
return None, schemas
119+
102120
@staticmethod
103121
def parse_request_form_body(body: oai.RequestBody) -> Optional[Reference]:
104122
""" Return form_body_reference """
@@ -110,7 +128,7 @@ def parse_request_form_body(body: oai.RequestBody) -> Optional[Reference]:
110128

111129
@staticmethod
112130
def parse_multipart_body(body: oai.RequestBody) -> Optional[Reference]:
113-
""" Return form_body_reference """
131+
""" Return multipart_body_reference """
114132
body_content = body.content
115133
json_body = body_content.get("multipart/form-data")
116134
if json_body is not None and isinstance(json_body.media_type_schema, oai.Reference):
@@ -150,6 +168,12 @@ def _add_body(
150168
if isinstance(json_body, ParseError):
151169
return ParseError(detail=f"cannot parse body of endpoint {endpoint.name}", data=json_body.data), schemas
152170

171+
yaml_body, schemas = Endpoint.parse_request_yaml_body(
172+
body=data.requestBody, schemas=schemas, parent_name=endpoint.name
173+
)
174+
if isinstance(yaml_body, ParseError):
175+
return ParseError(detail=f"cannot parse body of endpoint {endpoint.name}", data=yaml_body.data), schemas
176+
153177
endpoint.multipart_body_reference = Endpoint.parse_multipart_body(data.requestBody)
154178

155179
if endpoint.form_body_reference:
@@ -163,6 +187,10 @@ def _add_body(
163187
if json_body is not None:
164188
endpoint.json_body = json_body
165189
endpoint.relative_imports.update(endpoint.json_body.get_imports(prefix="..."))
190+
if yaml_body is not None:
191+
endpoint.yaml_body = yaml_body
192+
endpoint.relative_imports.update(endpoint.yaml_body.get_imports(prefix="..."))
193+
166194
return endpoint, schemas
167195

168196
@staticmethod
@@ -177,7 +205,7 @@ def _add_responses(*, endpoint: "Endpoint", data: oai.Responses, schemas: Schema
177205
ParseError(
178206
detail=(
179207
f"Cannot parse response for status code {code}, "
180-
f"response will be ommitted from generated client"
208+
f"response will be omitted from generated client"
181209
),
182210
data=response.data,
183211
)

openapi_python_client/parser/responses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Response:
2222
"application/json": "response.json()",
2323
"application/octet-stream": "response.content",
2424
"text/html": "response.text",
25+
"text/yaml": "response.yaml", # Only used as an identifier, not the actual source
2526
}
2627

2728

openapi_python_client/templates/endpoint_macros.pyi

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ if {% if not property.required %}not isinstance({{ property_name }}, Unset) and
5656
{% endif %}
5757
{% endmacro %}
5858

59+
{% macro yaml_body(endpoint) %}
60+
{% if endpoint.yaml_body %}
61+
{% set property = endpoint.yaml_body %}
62+
{% set destination = "yaml_" + property.python_name %}
63+
{% if property.template %}
64+
{% from "property_templates/" + property.template import transform %}
65+
{{ transform(property, property.python_name, destination) }}
66+
{% endif %}
67+
{% endif %}
68+
{% endmacro %}
69+
5970
{% macro return_type(endpoint) %}
6071
{% if endpoint.responses | length == 0 %}
6172
None
@@ -83,6 +94,10 @@ client: Client,
8394
{% for parameter in endpoint.path_parameters %}
8495
{{ parameter.to_string() }},
8596
{% endfor %}
97+
{# Yaml body if any #}
98+
{% if endpoint.yaml_body %}
99+
yaml_body: {{ endpoint.yaml_body.get_type_string() }},
100+
{% endif %}
86101
{# Form data if any #}
87102
{% if endpoint.form_body_reference %}
88103
form_data: {{ endpoint.form_body_reference.class_name }},
@@ -110,6 +125,9 @@ client=client,
110125
{% for parameter in endpoint.path_parameters %}
111126
{{ parameter.python_name }}={{ parameter.python_name }},
112127
{% endfor %}
128+
{% if endpoint.yaml_body %}
129+
yaml_body=yaml_body,
130+
{% endif %}
113131
{% if endpoint.form_body_reference %}
114132
form_data=form_data,
115133
{% endif %}

openapi_python_client/templates/endpoint_module.pyi

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union, cast
22

33
import httpx
44
from attr import asdict
5+
from ruamel.yaml import YAML
56

67
from ...client import AuthenticatedClient, Client
78
from ...types import Response
@@ -10,7 +11,7 @@ from ...types import Response
1011
{{ relative }}
1112
{% endfor %}
1213

13-
{% from "endpoint_macros.pyi" import header_params, query_params, json_body, return_type, arguments, client, kwargs, parse_response %}
14+
{% from "endpoint_macros.pyi" import header_params, query_params, json_body, yaml_body, return_type, arguments, client, kwargs, parse_response %}
1415

1516
{% set return_string = return_type(endpoint) %}
1617
{% set parsed_responses = (endpoint.responses | length > 0) and return_string != "None" %}
@@ -33,6 +34,8 @@ def _get_kwargs(
3334

3435
{{ json_body(endpoint) | indent(4) }}
3536

37+
{{ yaml_body(endpoint) | indent(4) }}
38+
3639
return {
3740
"url": url,
3841
"headers": headers,
@@ -46,7 +49,9 @@ def _get_kwargs(
4649
{% endif %}
4750
{% if endpoint.json_body %}
4851
"json": {{ "json_" + endpoint.json_body.python_name }},
49-
{% endif %}
52+
{%- elif endpoint.yaml_body %}
53+
"json": {{ "yaml_" + endpoint.yaml_body.python_name }},
54+
{%- endif %}
5055
{% if endpoint.query_parameters %}
5156
"params": params,
5257
{% endif %}

openapi_python_client/templates/property_templates/model_property.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{% macro construct(property, source, initial_value=None) %}
22
{% if property.required and not property.nullable %}
3+
{% if source == "response.yaml" %}
4+
yaml = YAML(typ="base")
5+
yaml_dict = yaml.load(response.text.encode('utf-8'))
6+
{{ property.python_name }} = {{ property.reference.class_name }}.from_dict(yaml_dict)
7+
{% else %}
38
{{ property.python_name }} = {{ property.reference.class_name }}.from_dict({{ source }})
9+
{% endif %}
410
{% else %}
511
{% if initial_value != None %}
612
{{ property.python_name }} = {{ initial_value }}

poetry.lock

Lines changed: 55 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ attrs = ">=20.1.0, <22.0"
3434
python-dateutil = "^2.8.1"
3535
httpx = ">=0.15.4,<=0.22.0"
3636
autoflake = "^1.4"
37+
"ruamel.yaml" = "^0.17.21"
3738

3839
[tool.poetry.scripts]
3940
openapi-python-client = "openapi_python_client.cli:app"
@@ -57,7 +58,8 @@ isort .\
5758
&& mypy openapi_python_client\
5859
&& task unit\
5960
"""
60-
unit = "pytest --cov openapi_python_client tests --cov-report=term-missing"
61+
code_coverage = "pytest --cov openapi_python_client tests --cov-report=term-missing"
62+
unit = "pytest openapi_python_client tests"
6163
regen = "python -m end_to_end_tests.regen_golden_record"
6264
regen_custom = "python -m end_to_end_tests.regen_golden_record custom"
6365
e2e = "pytest openapi_python_client end_to_end_tests/test_end_to_end.py"

tests/test_parser/test_openapi.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ def test_from_dict_invalid_schema(self, mocker):
6666

6767

6868
class TestEndpoint:
69+
def test_parse_yaml_body(self, mocker):
70+
from openapi_python_client.parser.openapi import Endpoint, Schemas
71+
schema = mocker.MagicMock()
72+
body = oai.RequestBody.construct(
73+
content={"text/yaml": oai.MediaType.construct(media_type_schema=schema)}
74+
)
75+
property_from_data = mocker.patch(f"{MODULE_NAME}.property_from_data")
76+
schemas = Schemas()
77+
78+
result = Endpoint.parse_request_yaml_body(body=body, schemas=schemas, parent_name="parent")
79+
80+
property_from_data.assert_called_once_with(
81+
name="yaml_body", required=True, data=schema, schemas=schemas, parent_name="parent"
82+
)
83+
assert result == property_from_data.return_value
84+
85+
def test_parse_yaml_body_no_data(self):
86+
from openapi_python_client.parser.openapi import Endpoint, Schemas
87+
88+
body = oai.RequestBody.construct(content={})
89+
schemas = Schemas()
90+
91+
result = Endpoint.parse_request_yaml_body(body=body, schemas=schemas, parent_name="parent")
92+
93+
assert result == (None, schemas)
94+
6995
def test_parse_request_form_body(self, mocker):
7096
ref = mocker.MagicMock()
7197
body = oai.RequestBody.construct(
@@ -211,6 +237,12 @@ def test_add_body_happy(self, mocker):
211237
parse_request_json_body = mocker.patch.object(
212238
Endpoint, "parse_request_json_body", return_value=(json_body, parsed_schemas)
213239
)
240+
yaml_body = mocker.MagicMock(autospec=Property)
241+
yaml_body_imports = mocker.MagicMock()
242+
yaml_body.get_imports.return_value = {yaml_body_imports}
243+
parse_request_yaml_body = mocker.patch.object(
244+
Endpoint, "parse_request_yaml_body", return_value=(yaml_body, parsed_schemas)
245+
)
214246
import_string_from_reference = mocker.patch(
215247
f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1", "import_2"]
216248
)
@@ -233,15 +265,18 @@ def test_add_body_happy(self, mocker):
233265
assert response_schemas == parsed_schemas
234266
parse_request_form_body.assert_called_once_with(request_body)
235267
parse_request_json_body.assert_called_once_with(body=request_body, schemas=initial_schemas, parent_name="name")
268+
parse_request_yaml_body.assert_called_once_with(body=request_body, schemas=parsed_schemas, parent_name="name")
236269
parse_multipart_body.assert_called_once_with(request_body)
237270
import_string_from_reference.assert_has_calls(
238271
[
239272
mocker.call(form_body_reference, prefix="...models"),
240273
mocker.call(multipart_body_reference, prefix="...models"),
241274
]
242275
)
276+
yaml_body.get_imports.assert_called_once_with(prefix="...")
243277
json_body.get_imports.assert_called_once_with(prefix="...")
244-
assert endpoint.relative_imports == {"import_1", "import_2", "import_3", json_body_imports}
278+
assert endpoint.relative_imports == {"import_1", "import_2", "import_3", yaml_body_imports, json_body_imports}
279+
assert endpoint.yaml_body == yaml_body
245280
assert endpoint.json_body == json_body
246281
assert endpoint.form_body_reference == form_body_reference
247282
assert endpoint.multipart_body_reference == multipart_body_reference
@@ -312,12 +347,12 @@ def test__add_responses(self, mocker):
312347
response_1 = Response(
313348
status_code=200,
314349
source="source",
315-
prop=DateTimeProperty(name="datetime", required=True, nullable=False, default=None),
350+
prop=DateTimeProperty(name="datetime", required=True, nullable=False, default=None, description=None),
316351
)
317352
response_2 = Response(
318353
status_code=404,
319354
source="source",
320-
prop=DateProperty(name="date", required=True, nullable=False, default=None),
355+
prop=DateProperty(name="date", required=True, nullable=False, default=None, description=None),
321356
)
322357
response_from_data = mocker.patch(
323358
f"{MODULE_NAME}.response_from_data", side_effect=[(response_1, schemas_1), (response_2, schemas_2)]

0 commit comments

Comments
 (0)