From dd59e4cbd77dc228a578f5eb61f1e9c9fade3b20 Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Wed, 5 Aug 2020 12:30:17 -0400 Subject: [PATCH] Improve error message when OpenAPI document fails validation (closes #129) --- CHANGELOG.md | 1 + openapi_python_client/__init__.py | 10 +++- openapi_python_client/parser/openapi.py | 11 +++-- tests/test___init__.py | 57 ++++++++++++++++++++++- tests/test_openapi_parser/test_openapi.py | 26 +++++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da5aa5c86..d8c4473a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.5.1 - Unreleased ### Fixes - Relative paths are now allowed in securitySchemes/OAuthFlow/tokenUrl (#130). +- Schema validation errors will no longer print a stack trace (#131). ## 0.5.0 - 2020-08-05 diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index 6e33fcd1f..0b753c5fc 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -5,7 +5,7 @@ import subprocess import sys from pathlib import Path -from typing import Any, Dict, Optional, Sequence +from typing import Any, Dict, Optional, Sequence, Union import httpx import yaml @@ -25,9 +25,11 @@ __version__ = version(__package__) -def _get_project_for_url_or_path(url: Optional[str], path: Optional[Path]) -> _Project: +def _get_project_for_url_or_path(url: Optional[str], path: Optional[Path]) -> Union[_Project, GeneratorError]: data_dict = _get_document(url=url, path=path) openapi = GeneratorData.from_dict(data_dict) + if isinstance(openapi, GeneratorError): + return openapi return _Project(openapi=openapi) @@ -50,6 +52,8 @@ def create_new_client(*, url: Optional[str], path: Optional[Path]) -> Sequence[G A list containing any errors encountered when generating. """ project = _get_project_for_url_or_path(url=url, path=path) + if isinstance(project, GeneratorError): + return [project] return project.build() @@ -61,6 +65,8 @@ def update_existing_client(*, url: Optional[str], path: Optional[Path]) -> Seque A list containing any errors encountered when generating. """ project = _get_project_for_url_or_path(url=url, path=path) + if isinstance(project, GeneratorError): + return [project] return project.update() diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index 76153fce3..c4a6e1b67 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -5,8 +5,10 @@ from enum import Enum from typing import Any, Dict, List, Optional, Set, Union +from pydantic import ValidationError + from .. import schema as oai -from .errors import ParseError, PropertyError +from .errors import GeneratorError, ParseError, PropertyError from .properties import EnumProperty, Property, property_from_data from .reference import Reference from .responses import ListRefResponse, RefResponse, Response, response_from_data @@ -288,9 +290,12 @@ class GeneratorData: enums: Dict[str, EnumProperty] @staticmethod - def from_dict(d: Dict[str, Dict[str, Any]]) -> GeneratorData: + def from_dict(d: Dict[str, Dict[str, Any]]) -> Union[GeneratorData, GeneratorError]: """ Create an OpenAPI from dict """ - openapi = oai.OpenAPI.parse_obj(d) + try: + openapi = oai.OpenAPI.parse_obj(d) + except ValidationError as e: + return GeneratorError(header="Failed to parse OpenAPI document", detail=str(e)) if openapi.components is None or openapi.components.schemas is None: schemas = Schemas() else: diff --git a/tests/test___init__.py b/tests/test___init__.py index 9a975b7ae..809dd40ba 100644 --- a/tests/test___init__.py +++ b/tests/test___init__.py @@ -25,6 +25,25 @@ def test__get_project_for_url_or_path(mocker): assert project == _Project() +def test__get_project_for_url_or_path_generator_error(mocker): + data_dict = mocker.MagicMock() + _get_document = mocker.patch("openapi_python_client._get_document", return_value=data_dict) + error = GeneratorError() + from_dict = mocker.patch("openapi_python_client.parser.GeneratorData.from_dict", return_value=error) + _Project = mocker.patch("openapi_python_client._Project") + url = mocker.MagicMock() + path = mocker.MagicMock() + + from openapi_python_client import _get_project_for_url_or_path + + project = _get_project_for_url_or_path(url=url, path=path) + + _get_document.assert_called_once_with(url=url, path=path) + from_dict.assert_called_once_with(data_dict) + _Project.assert_not_called() + assert project == error + + def test_create_new_client(mocker): project = mocker.MagicMock() _get_project_for_url_or_path = mocker.patch( @@ -35,10 +54,27 @@ def test_create_new_client(mocker): from openapi_python_client import create_new_client - create_new_client(url=url, path=path) + result = create_new_client(url=url, path=path) _get_project_for_url_or_path.assert_called_once_with(url=url, path=path) project.build.assert_called_once() + assert result == project.build.return_value + + +def test_create_new_client_project_error(mocker): + error = GeneratorError() + _get_project_for_url_or_path = mocker.patch( + "openapi_python_client._get_project_for_url_or_path", return_value=error + ) + url = mocker.MagicMock() + path = mocker.MagicMock() + + from openapi_python_client import create_new_client + + result = create_new_client(url=url, path=path) + + _get_project_for_url_or_path.assert_called_once_with(url=url, path=path) + assert result == [error] def test_update_existing_client(mocker): @@ -51,10 +87,27 @@ def test_update_existing_client(mocker): from openapi_python_client import update_existing_client - update_existing_client(url=url, path=path) + result = update_existing_client(url=url, path=path) _get_project_for_url_or_path.assert_called_once_with(url=url, path=path) project.update.assert_called_once() + assert result == project.update.return_value + + +def test_update_existing_client_project_error(mocker): + error = GeneratorError() + _get_project_for_url_or_path = mocker.patch( + "openapi_python_client._get_project_for_url_or_path", return_value=error + ) + url = mocker.MagicMock() + path = mocker.MagicMock() + + from openapi_python_client import update_existing_client + + result = update_existing_client(url=url, path=path) + + _get_project_for_url_or_path.assert_called_once_with(url=url, path=path) + assert result == [error] class TestGetJson: diff --git a/tests/test_openapi_parser/test_openapi.py b/tests/test_openapi_parser/test_openapi.py index a879f11bc..e1543e7fe 100644 --- a/tests/test_openapi_parser/test_openapi.py +++ b/tests/test_openapi_parser/test_openapi.py @@ -1,4 +1,8 @@ +from pydantic import ValidationError +from pydantic.error_wrappers import ErrorWrapper + import openapi_python_client.schema as oai +from openapi_python_client import GeneratorError from openapi_python_client.parser.errors import ParseError MODULE_NAME = "openapi_python_client.parser.openapi" @@ -40,6 +44,28 @@ def test_from_dict(self, mocker): Schemas.build.assert_not_called() assert generator_data.schemas == Schemas() + def test_from_dict_invalid_schema(self, mocker): + Schemas = mocker.patch(f"{MODULE_NAME}.Schemas") + + in_dict = {} + + from openapi_python_client.parser.openapi import GeneratorData + + generator_data = GeneratorData.from_dict(in_dict) + + assert generator_data == GeneratorError( + header="Failed to parse OpenAPI document", + detail=( + "2 validation errors for OpenAPI\n" + "info\n" + " field required (type=value_error.missing)\n" + "paths\n" + " field required (type=value_error.missing)" + ), + ) + Schemas.build.assert_not_called() + Schemas.assert_not_called() + class TestModel: def test_from_data(self, mocker):