Skip to content

Commit b01db03

Browse files
dbantytardyp
andauthored
fix: properly support JSON OpenAPI documents and config files [#488, #509, #515]. Thanks @tardyp and @Gelbpunkt! (#515)
Co-authored-by: Pierre Tardy <[email protected]>
1 parent a46c05a commit b01db03

File tree

5 files changed

+120
-42
lines changed

5 files changed

+120
-42
lines changed

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
## Writing Code
1717

1818
1. Write some code and make sure it's covered by unit tests. All unit tests are in the `tests` directory and the file structure should mirror the structure of the source code in the `openapi_python_client` directory.
19+
20+
### Run Checks and Tests
21+
1922
2. When in a Poetry shell (`poetry shell`) run `task check` in order to run most of the same checks CI runs. This will auto-reformat the code, check type annotations, run unit tests, check code coverage, and lint the code.
23+
24+
### Rework end to end tests
25+
2026
3. If you're writing a new feature, try to add it to the end to end test.
2127
1. If adding support for a new OpenAPI feature, add it somewhere in `end_to_end_tests/openapi.json`
2228
2. Regenerate the "golden records" with `task regen`. This client is generated from the OpenAPI document used for end to end testing.

openapi_python_client/__init__.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
""" Generate modern Python clients from OpenAPI """
22

3+
import json
4+
import mimetypes
35
import shutil
46
import subprocess
57
import sys
@@ -361,21 +363,40 @@ def update_existing_client(
361363
return project.update()
362364

363365

366+
def _load_yaml_or_json(data: bytes, content_type: Optional[str]) -> Union[Dict[str, Any], GeneratorError]:
367+
if content_type == "application/json":
368+
try:
369+
return json.loads(data.decode())
370+
except ValueError as err:
371+
return GeneratorError(header="Invalid JSON from provided source: {}".format(str(err)))
372+
else:
373+
try:
374+
return yaml.safe_load(data)
375+
except yaml.YAMLError as err:
376+
return GeneratorError(header="Invalid YAML from provided source: {}".format(str(err)))
377+
378+
364379
def _get_document(*, url: Optional[str], path: Optional[Path]) -> Union[Dict[str, Any], GeneratorError]:
365380
yaml_bytes: bytes
381+
content_type: Optional[str]
366382
if url is not None and path is not None:
367383
return GeneratorError(header="Provide URL or Path, not both.")
368384
if url is not None:
369385
try:
370386
response = httpx.get(url)
371387
yaml_bytes = response.content
388+
if "content-type" in response.headers:
389+
content_type = response.headers["content-type"].split(";")[0]
390+
else:
391+
content_type = mimetypes.guess_type(url, strict=True)[0]
392+
372393
except (httpx.HTTPError, httpcore.NetworkError):
373394
return GeneratorError(header="Could not get OpenAPI document from provided URL")
374395
elif path is not None:
375396
yaml_bytes = path.read_bytes()
397+
content_type = mimetypes.guess_type(path.as_uri(), strict=True)[0]
398+
376399
else:
377400
return GeneratorError(header="No URL or Path provided")
378-
try:
379-
return yaml.safe_load(yaml_bytes)
380-
except yaml.YAMLError:
381-
return GeneratorError(header="Invalid YAML from provided source")
401+
402+
return _load_yaml_or_json(yaml_bytes, content_type)

openapi_python_client/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import mimetypes
13
from pathlib import Path
24
from typing import Dict, List, Optional
35

@@ -35,6 +37,10 @@ class Config(BaseModel):
3537
@staticmethod
3638
def load_from_path(path: Path) -> "Config":
3739
"""Creates a Config from provided JSON or YAML file and sets a bunch of globals from it"""
38-
config_data = yaml.safe_load(path.read_text())
40+
mime = mimetypes.guess_type(path.as_uri(), strict=True)[0]
41+
if mime == "application/json":
42+
config_data = json.loads(path.read_text())
43+
else:
44+
config_data = yaml.safe_load(path.read_text())
3945
config = Config(**config_data)
4046
return config

tests/test___init__.py

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import httpcore
44
import jinja2
55
import pytest
6-
import yaml
76

87
from openapi_python_client import Config, ErrorLevel, GeneratorError, Project
98

@@ -148,7 +147,7 @@ def test_update_existing_client_project_error(mocker):
148147
class TestGetJson:
149148
def test__get_document_no_url_or_path(self, mocker):
150149
get = mocker.patch("httpx.get")
151-
Path = mocker.patch("openapi_python_client.Path")
150+
_Path = mocker.patch("openapi_python_client.Path")
152151
loads = mocker.patch("yaml.safe_load")
153152

154153
from openapi_python_client import _get_document
@@ -157,12 +156,12 @@ def test__get_document_no_url_or_path(self, mocker):
157156

158157
assert result == GeneratorError(header="No URL or Path provided")
159158
get.assert_not_called()
160-
Path.assert_not_called()
159+
_Path.assert_not_called()
161160
loads.assert_not_called()
162161

163162
def test__get_document_url_and_path(self, mocker):
164163
get = mocker.patch("httpx.get")
165-
Path = mocker.patch("openapi_python_client.Path")
164+
_Path = mocker.patch("openapi_python_client.Path")
166165
loads = mocker.patch("yaml.safe_load")
167166

168167
from openapi_python_client import _get_document
@@ -171,12 +170,12 @@ def test__get_document_url_and_path(self, mocker):
171170

172171
assert result == GeneratorError(header="Provide URL or Path, not both.")
173172
get.assert_not_called()
174-
Path.assert_not_called()
173+
_Path.assert_not_called()
175174
loads.assert_not_called()
176175

177176
def test__get_document_bad_url(self, mocker):
178177
get = mocker.patch("httpx.get", side_effect=httpcore.NetworkError)
179-
Path = mocker.patch("openapi_python_client.Path")
178+
_Path = mocker.patch("openapi_python_client.Path")
180179
loads = mocker.patch("yaml.safe_load")
181180

182181
from openapi_python_client import _get_document
@@ -186,49 +185,84 @@ def test__get_document_bad_url(self, mocker):
186185

187186
assert result == GeneratorError(header="Could not get OpenAPI document from provided URL")
188187
get.assert_called_once_with(url)
189-
Path.assert_not_called()
188+
_Path.assert_not_called()
190189
loads.assert_not_called()
191190

192191
def test__get_document_url_no_path(self, mocker):
193192
get = mocker.patch("httpx.get")
194-
Path = mocker.patch("openapi_python_client.Path")
193+
_Path = mocker.patch("openapi_python_client.Path")
195194
loads = mocker.patch("yaml.safe_load")
196195

197196
from openapi_python_client import _get_document
198197

199-
url = mocker.MagicMock()
198+
url = "test"
200199
_get_document(url=url, path=None)
201200

202201
get.assert_called_once_with(url)
203-
Path.assert_not_called()
202+
_Path.assert_not_called()
204203
loads.assert_called_once_with(get().content)
205204

206-
def test__get_document_path_no_url(self, mocker):
205+
def test__get_document_path_no_url(self, tmp_path, mocker):
207206
get = mocker.patch("httpx.get")
208207
loads = mocker.patch("yaml.safe_load")
208+
path = tmp_path / "test.yaml"
209+
path.write_text("some test data")
209210

210211
from openapi_python_client import _get_document
211212

212-
path = mocker.MagicMock()
213213
_get_document(url=None, path=path)
214214

215215
get.assert_not_called()
216-
path.read_bytes.assert_called_once()
217-
loads.assert_called_once_with(path.read_bytes())
216+
loads.assert_called_once_with(b"some test data")
218217

219-
def test__get_document_bad_yaml(self, mocker):
218+
def test__get_document_bad_yaml(self, mocker, tmp_path):
220219
get = mocker.patch("httpx.get")
221-
loads = mocker.patch("yaml.safe_load", side_effect=yaml.YAMLError)
222-
223220
from openapi_python_client import _get_document
224221

225-
path = mocker.MagicMock()
222+
path = tmp_path / "test.yaml"
223+
path.write_text("'")
226224
result = _get_document(url=None, path=path)
227225

228226
get.assert_not_called()
229-
path.read_bytes.assert_called_once()
230-
loads.assert_called_once_with(path.read_bytes())
231-
assert result == GeneratorError(header="Invalid YAML from provided source")
227+
assert isinstance(result, GeneratorError)
228+
assert "Invalid YAML" in result.header
229+
230+
def test__get_document_json(self, mocker):
231+
class FakeResponse:
232+
content = b'{\n\t"foo": "bar"}'
233+
headers = {"content-type": "application/json; encoding=utf8"}
234+
235+
get = mocker.patch("httpx.get", return_value=FakeResponse())
236+
yaml_loads = mocker.patch("yaml.safe_load")
237+
json_result = mocker.MagicMock()
238+
json_loads = mocker.patch("json.loads", return_value=json_result)
239+
240+
from openapi_python_client import _get_document
241+
242+
url = mocker.MagicMock()
243+
result = _get_document(url=url, path=None)
244+
245+
get.assert_called_once()
246+
json_loads.assert_called_once_with(FakeResponse.content.decode())
247+
yaml_loads.assert_not_called()
248+
assert result == json_result
249+
250+
def test__get_document_bad_json(self, mocker):
251+
class FakeResponse:
252+
content = b'{"foo"}'
253+
headers = {"content-type": "application/json; encoding=utf8"}
254+
255+
get = mocker.patch("httpx.get", return_value=FakeResponse())
256+
257+
from openapi_python_client import _get_document
258+
259+
url = mocker.MagicMock()
260+
result = _get_document(url=url, path=None)
261+
262+
get.assert_called_once()
263+
assert result == GeneratorError(
264+
header="Invalid JSON from provided source: " "Expecting ':' delimiter: line 1 column 7 (char 6)"
265+
)
232266

233267

234268
def make_project(**kwargs):

tests/test_config.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
1-
import pathlib
1+
import json
2+
3+
import pytest
4+
import yaml
25

36
from openapi_python_client.config import Config
47

58

6-
def test_load_from_path(mocker):
7-
from openapi_python_client import utils
9+
def json_with_tabs(d):
10+
return json.dumps(d, indent=4).replace(" ", "\t")
11+
812

13+
@pytest.mark.parametrize(
14+
"filename,dump",
15+
[
16+
("example.yml", yaml.dump),
17+
("example.json", json.dumps),
18+
("example.yaml", yaml.dump),
19+
("example.json", json_with_tabs),
20+
],
21+
)
22+
def test_load_from_path(tmp_path, filename, dump):
23+
yml_file = tmp_path.joinpath(filename)
924
override1 = {"class_name": "ExampleClass", "module_name": "example_module"}
1025
override2 = {"class_name": "DifferentClass", "module_name": "different_module"}
11-
safe_load = mocker.patch(
12-
"yaml.safe_load",
13-
return_value={
14-
"field_prefix": "blah",
15-
"class_overrides": {"Class1": override1, "Class2": override2},
16-
"project_name_override": "project-name",
17-
"package_name_override": "package_name",
18-
"package_version_override": "package_version",
19-
},
20-
)
21-
fake_path = mocker.MagicMock(autospec=pathlib.Path)
26+
data = {
27+
"field_prefix": "blah",
28+
"class_overrides": {"Class1": override1, "Class2": override2},
29+
"project_name_override": "project-name",
30+
"package_name_override": "package_name",
31+
"package_version_override": "package_version",
32+
}
33+
yml_file.write_text(dump(data))
2234

23-
config = Config.load_from_path(fake_path)
24-
safe_load.assert_called()
35+
config = Config.load_from_path(yml_file)
2536
assert config.field_prefix == "blah"
2637
assert config.class_overrides["Class1"] == override1
2738
assert config.class_overrides["Class2"] == override2

0 commit comments

Comments
 (0)