diff --git a/doc/changelog.d/778.added.md b/doc/changelog.d/778.added.md new file mode 100644 index 00000000..1f20b2f4 --- /dev/null +++ b/doc/changelog.d/778.added.md @@ -0,0 +1 @@ +Deserialize API responses for non 2XX status codes if defined \ No newline at end of file diff --git a/src/ansys/openapi/common/_api_client.py b/src/ansys/openapi/common/_api_client.py index 9c7fdd79..db6493e2 100644 --- a/src/ansys/openapi/common/_api_client.py +++ b/src/ansys/openapi/common/_api_client.py @@ -50,7 +50,8 @@ from ._base import ApiClientBase, DeserializedType, ModelBase, PrimitiveType, SerializedType, Unset from ._exceptions import ApiException, UndefinedObjectWarning -from ._util import SessionConfiguration, handle_response +from ._logger import logger +from ._util import SessionConfiguration # noinspection DuplicatedCode @@ -205,6 +206,7 @@ def __call_api( ) self.last_response = response_data + logger.debug(f"response body: {response_data.text}") return_data: Union[requests.Response, DeserializedType, None] = response_data if _preload_content: @@ -212,7 +214,13 @@ def __call_api( if response_type_map is not None: _response_type = response_type_map.get(response_data.status_code, None) - return_data = self.deserialize(response_data, _response_type) + deserialized_response = self.deserialize(response_data, _response_type) + if not 200 <= response_data.status_code <= 299: + raise ApiException.from_response(response_data, deserialized_response) + return_data = deserialized_response + else: + if not 200 <= response_data.status_code <= 299: + raise ApiException.from_response(response_data) if _return_http_data_only: return return_data @@ -527,83 +535,69 @@ def request( timeout setting. """ if method == "GET": - return handle_response( - self.rest_client.get( - url, - params=query_params, - stream=_preload_content, - timeout=_request_timeout, - headers=headers, - ) + return self.rest_client.get( + url, + params=query_params, + stream=_preload_content, + timeout=_request_timeout, + headers=headers, ) elif method == "HEAD": - return handle_response( - self.rest_client.head( - url, - params=query_params, - stream=_preload_content, - timeout=_request_timeout, - headers=headers, - ) + return self.rest_client.head( + url, + params=query_params, + stream=_preload_content, + timeout=_request_timeout, + headers=headers, ) elif method == "OPTIONS": - return handle_response( - self.rest_client.options( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) + return self.rest_client.options( + url, + params=query_params, + headers=headers, + files=post_params, + stream=_preload_content, + timeout=_request_timeout, + data=body, ) elif method == "POST": - return handle_response( - self.rest_client.post( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) + return self.rest_client.post( + url, + params=query_params, + headers=headers, + files=post_params, + stream=_preload_content, + timeout=_request_timeout, + data=body, ) elif method == "PUT": - return handle_response( - self.rest_client.put( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) + return self.rest_client.put( + url, + params=query_params, + headers=headers, + files=post_params, + stream=_preload_content, + timeout=_request_timeout, + data=body, ) elif method == "PATCH": - return handle_response( - self.rest_client.patch( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) + return self.rest_client.patch( + url, + params=query_params, + headers=headers, + files=post_params, + stream=_preload_content, + timeout=_request_timeout, + data=body, ) elif method == "DELETE": - return handle_response( - self.rest_client.delete( - url, - params=query_params, - headers=headers, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) + return self.rest_client.delete( + url, + params=query_params, + headers=headers, + stream=_preload_content, + timeout=_request_timeout, + data=body, ) else: raise ValueError( diff --git a/src/ansys/openapi/common/_exceptions.py b/src/ansys/openapi/common/_exceptions.py index 4ebd6d05..51249100 100644 --- a/src/ansys/openapi/common/_exceptions.py +++ b/src/ansys/openapi/common/_exceptions.py @@ -20,14 +20,15 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import Optional +from typing import TYPE_CHECKING, Optional from requests.structures import CaseInsensitiveDict -MYPY = False -if MYPY: +if TYPE_CHECKING: import requests + from ansys.openapi.common._base._types import DeserializedType + class ApiConnectionException(Exception): """ @@ -74,7 +75,8 @@ class ApiException(Exception): """ Provides the exception to raise when the remote server returns an unsuccessful response. - For more information about the failure, inspect ``.status_code`` and ``.reason_phrase``. + For more information about the failure, inspect ``.status_code`` and ``.reason_phrase``. If the + server defines a custom exception model, ``.exception_model`` contains the deserialized response. Parameters ---------- @@ -84,6 +86,8 @@ class ApiException(Exception): Description of the response provided by the server. body : str, optional Content of the response provided by the server. The default is ``None``. + exception_model: ModelBase, optional + The custom exception model if defined by the server. The default is ``None``. headers : CaseInsensitiveDict, optional Response headers provided by the server. The default is ``None``. """ @@ -91,6 +95,7 @@ class ApiException(Exception): status_code: int reason_phrase: str body: Optional[str] + exception_model: "DeserializedType" headers: Optional[CaseInsensitiveDict] def __init__( @@ -98,20 +103,25 @@ def __init__( status_code: int, reason_phrase: str, body: Optional[str] = None, + exception_model: "DeserializedType" = None, headers: Optional[CaseInsensitiveDict] = None, ): self.status_code = status_code self.reason_phrase = reason_phrase self.body = body + self.exception_model = exception_model self.headers = headers @classmethod - def from_response(cls, http_response: "requests.Response") -> "ApiException": + def from_response( + cls, http_response: "requests.Response", exception_model: "DeserializedType" = None + ) -> "ApiException": """Initialize object from a requests.Response object.""" new = cls( status_code=http_response.status_code, reason_phrase=http_response.reason, body=http_response.text, + exception_model=exception_model, headers=http_response.headers, ) return new diff --git a/src/ansys/openapi/common/_session.py b/src/ansys/openapi/common/_session.py index 802c7bde..a0f981b2 100644 --- a/src/ansys/openapi/common/_session.py +++ b/src/ansys/openapi/common/_session.py @@ -22,7 +22,7 @@ from enum import Enum import os -from typing import Any, Literal, Mapping, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, Literal, Mapping, Optional, Tuple, TypeVar, Union import warnings import requests @@ -43,7 +43,6 @@ set_session_kwargs, ) -TYPE_CHECKING = False if TYPE_CHECKING: from ._util import CaseInsensitiveOrderedDict diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index ff07cf32..fa11f03c 100644 --- a/src/ansys/openapi/common/_util.py +++ b/src/ansys/openapi/common/_util.py @@ -23,16 +23,23 @@ import http.cookiejar from itertools import chain import tempfile -from typing import Any, Collection, Dict, List, Optional, Tuple, TypedDict, Union, cast +from typing import ( + Any, + Collection, + Dict, + List, + Optional, + Tuple, + TypedDict, + Union, + cast, +) import pyparsing as pp from pyparsing import Word import requests from requests.structures import CaseInsensitiveDict -from ._exceptions import ApiException -from ._logger import logger - class CaseInsensitiveOrderedDict(OrderedDict): """Preserves order of insertion and is case-insensitive. @@ -359,27 +366,6 @@ def from_dict(cls, configuration_dict: "RequestsConfiguration") -> "SessionConfi return new -def handle_response(response: requests.Response) -> requests.Response: - """Check the status code of a response. - - If the response is 2XX, it is returned as-is. Otherwise an :class:`ApiException` class will be raised. - - Throws - ------ - ApiException - If the status code was not 2XX. - - Parameters - ---------- - response : requests.Response - Response from the API server. - """ - logger.debug(f"response body: {response.text}") - if not 200 <= response.status_code <= 299: - raise ApiException.from_response(response) - return response - - def generate_user_agent(package_name: str, package_version: str) -> str: """Generate a user-agent string in the form * *. diff --git a/tests/models/__init__.py b/tests/models/__init__.py index 7a000964..bfb01a31 100644 --- a/tests/models/__init__.py +++ b/tests/models/__init__.py @@ -22,6 +22,7 @@ from .example_base_model import ExampleBaseModel from .example_enum import ExampleEnum +from .example_exception import ExampleException from .example_int_enum import ExampleIntEnum from .example_model import ExampleModel from .example_model_with_enum import ExampleModelWithEnum diff --git a/tests/models/example_exception.py b/tests/models/example_exception.py new file mode 100644 index 00000000..7bd39234 --- /dev/null +++ b/tests/models/example_exception.py @@ -0,0 +1,103 @@ +# Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from ansys.openapi.common import ModelBase, Unset + + +class ExampleException(ModelBase): + """NOTE: This class is auto generated by the swagger code generator program. + Do not edit the class manually. + """ + + """ + Attributes: + swagger_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + swagger_types = { + "exception_text": "str", + "exception_code": "int", + "stack_trace": "list[str]", + } + + attribute_map = { + "exception_text": "ExceptionText", + "exception_code": "ExceptionCode", + "stack_trace": "StackTrace", + } + + subtype_mapping = {} + + def __init__( + self, + exception_text=Unset, + exception_code=Unset, + stack_trace=Unset, + ): # noqa: E501 + self._exception_text = (None,) + self._exception_code = (None,) + self._stack_trace = (None,) + self.discriminator = None + if exception_text is not None: + self._exception_text = exception_text + if exception_code is not None: + self._exception_code = exception_code + if stack_trace is not None: + self._stack_trace = stack_trace + + @property + def exception_text(self): + return self._exception_text + + @exception_text.setter + def exception_text(self, exception_text): + self._exception_text = exception_text + + @property + def exception_code(self): + return self._exception_code + + @exception_code.setter + def exception_code(self, exception_code): + self._exception_code = exception_code + + @property + def stack_trace(self): + return self._stack_trace + + @stack_trace.setter + def stack_trace(self, stack_trace): + self._stack_trace = stack_trace + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + return self.__dict__ == other.__dict__ + + def __ne__(self, other): + """Returns true if both objects are not equal""" + return not self == other diff --git a/tests/test_api_client.py b/tests/test_api_client.py index bbeed32e..ac2f67c4 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -44,6 +44,8 @@ UndefinedObjectWarning, ) +from .models import ExampleException + TEST_URL = "http://localhost/api/v1.svc" UA_STRING = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0" @@ -653,7 +655,7 @@ def send_request(self, verb: str): _request_timeout=self.timeout, ) - def assert_responses(self, verb, request_mock, handler_mock): + def assert_responses(self, verb, request_mock): kwarg_assertions = { "params": self.query_params, "stream": self.stream, @@ -667,8 +669,6 @@ def assert_responses(self, verb, request_mock, handler_mock): request_mock.assert_called() request_mock.assert_called_once_with(self.url, **kwarg_assertions) - handler_mock.assert_called() - handler_mock.assert_called_once_with(True) @pytest.fixture(autouse=True) def _blank_client(self): @@ -681,10 +681,8 @@ def test_request_dispatch(self, mocker, verb, method_call): # function above? request_mock = mocker.patch.object(requests.Session, method_call) request_mock.return_value = True - handler_mock = mocker.patch("ansys.openapi.common._api_client.handle_response") - handler_mock.return_value = True _ = self.send_request(verb) - self.assert_responses(verb, request_mock, handler_mock) + self.assert_responses(verb, request_mock) def test_invalid_verb(self): with pytest.raises(ValueError): @@ -696,11 +694,11 @@ class TestResponseHandling: @pytest.fixture(autouse=True) def _blank_client(self): - from .models import example_model + from . import models self._transport = requests.Session() self._client = ApiClient(self._transport, TEST_URL, SessionConfiguration()) - self._client.setup_client(example_model) + self._client.setup_client(models) self._adapter = requests_mock.Adapter() self._transport.mount(TEST_URL, self._adapter) @@ -778,6 +776,175 @@ def content_json_matcher(request: _RequestObjectProxy): assert "Content-Type" in headers assert headers["Content-Type"] == "text/plain" + def test_get_model_raises_exception_with_deserialized_response(self): + """This test represents getting an object from a server which returns a defined exception object when the + requested id does not exist.""" + + resource_path = "/items" + method = "GET" + + expected_url = TEST_URL + resource_path + + exception_text = "Item not found" + exception_code = 1 + stack_trace = [ + "Source lines", + "101: if id_ not in items:", + "102: raise ItemNotFound(id_)", + ] + + response = { + "ExceptionText": exception_text, + "ExceptionCode": exception_code, + "StackTrace": stack_trace, + } + response_type_map = {200: "ExampleModel", 404: "ExampleException"} + + self._adapter.register_uri( + "GET", + expected_url, + status_code=404, + json=response, + headers={"Content-Type": "application/json"}, + ) + + with pytest.raises(ApiException) as e: + _, _, _ = self._client.call_api( + resource_path, method, response_type_map=response_type_map + ) + + assert e.value.status_code == 404 + assert "Content-Type" in e.value.headers + assert e.value.headers["Content-Type"] == "application/json" + exception_model = e.value.exception_model + assert exception_model is not None + assert isinstance(exception_model, ExampleException) + assert exception_model.exception_text == exception_text + assert exception_model.exception_code == exception_code + assert exception_model.stack_trace == stack_trace + + def test_get_model_raises_exception_with_no_deserialized_response(self): + """This test represents getting an object from a server which returns a defined exception object when the + requested id does not exist.""" + + resource_path = "/items" + method = "GET" + + expected_url = TEST_URL + resource_path + + exception_text = "Item not found" + exception_code = 1 + stack_trace = [ + "Source lines", + "101: if id_ not in items:", + "102: raise ItemNotFound(id_)", + ] + + response = { + "ExceptionText": exception_text, + "ExceptionCode": exception_code, + "StackTrace": stack_trace, + } + response_type_map = {200: "ExampleModel", 404: "ExampleException"} + + self._adapter.register_uri( + "GET", + expected_url, + status_code=500, + json=response, + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(ApiException) as e: + _, _, _ = self._client.call_api( + resource_path, method, response_type_map=response_type_map + ) + assert e.value.status_code == 500 + assert exception_text in e.value.body + assert "Content-Type" in e.value.headers + assert e.value.headers["Content-Type"] == "application/json" + + def test_get_object_with_preload_false_returns_raw_response(self): + """This test represents getting an object from a server where we do not want to deserialize the response + immediately""" + + resource_path = "/items/1" + method = "GET" + + expected_url = TEST_URL + resource_path + + api_response = { + "String": "new_model", + "Integer": 1, + "ListOfStrings": ["red", "yellow", "green"], + "Boolean": False, + } + response_type_map = {200: "ExampleModel"} + + with requests_mock.Mocker() as m: + m.get( + expected_url, + status_code=200, + json=api_response, + headers={"Content-Type": "application/json"}, + ) + response = self._client.call_api( + resource_path, + method, + response_type_map=response_type_map, + _preload_content=False, + _return_http_data_only=True, + ) + + assert isinstance(response, requests.Response) + assert response.status_code == 200 + assert response.text == json.dumps(api_response) + + def test_get_object_with_preload_false_raises_exception(self): + """This test represents getting an object from a server where we do not want to deserialize the response + immediately, but an exception is returned.""" + + resource_path = "/items/1" + method = "GET" + + expected_url = TEST_URL + resource_path + + exception_text = "Item not found" + exception_code = 1 + stack_trace = [ + "Source lines", + "101: if id_ not in items:", + "102: raise ItemNotFound(id_)", + ] + + api_response = { + "ExceptionText": exception_text, + "ExceptionCode": exception_code, + "StackTrace": stack_trace, + } + + response_type_map = {200: "ExampleModel", 404: "ExampleException"} + + with requests_mock.Mocker() as m: + m.get( + expected_url, + status_code=404, + json=api_response, + reason="Not Found", + headers={"Content-Type": "application/json"}, + ) + with pytest.raises(ApiException) as e: + _ = self._client.call_api( + resource_path, + method, + response_type_map=response_type_map, + _preload_content=False, + _return_http_data_only=True, + ) + + assert e.value.status_code == 404 + assert e.value.reason_phrase == "Not Found" + assert e.value.body == json.dumps(api_response) + def test_patch_object(self): """This test represents updating a value on an existing record using a custom json payload. The new object is returned. This questionable API accepts an ID as a query param and returns the updated object @@ -934,11 +1101,7 @@ def content_json_matcher(request: _RequestObjectProxy): assert excinfo.value.reason_phrase == "Payload Too Large" -_RESPONSE_TYPE_MAP = { - 200: "ExampleModel", - 201: "str", - 202: None, -} +_RESPONSE_TYPE_MAP = {200: "ExampleModel", 201: "str", 202: None, 404: "ExampleException"} class TestMultipleResponseTypesHandling: @@ -965,6 +1128,7 @@ def _blank_client(self): # Check that response_type is used for deserialization if a response type map is not provided (200, None, "ExampleModel", "ExampleModel"), (200, None, "str", "str"), + (200, None, "ExampleException", "ExampleException"), # Check that if both the response_type and response_type_map are provided, the map takes precedence (200, _RESPONSE_TYPE_MAP, "str", "ExampleModel"), (201, _RESPONSE_TYPE_MAP, "ExampleModel", "str"), @@ -972,6 +1136,7 @@ def _blank_client(self): # Even if the map is empty. (200, {}, "str", None), (200, {}, "ExampleModel", None), + (200, {}, "ExampleException", None), # If neither are provided, check there is no attempt to deserialize (200, None, None, None), ], @@ -1005,6 +1170,55 @@ def test_response_type_handling( last_call_pos_args = deserialize_mock.call_args[0] assert last_call_pos_args[1] == expected_type + @pytest.mark.parametrize( + ["response_code", "response_type_map", "response_type", "expected_type"], + [ + # Check that the correct type is used for deserialization when a response type map is provided + (404, _RESPONSE_TYPE_MAP, None, "ExampleException"), + (400, _RESPONSE_TYPE_MAP, None, None), + # Check that response_type is used for deserialization if a response type map is not provided + (404, None, "ExampleException", "ExampleException"), + (400, None, "ExampleException", "ExampleException"), + # Check that if both the response_type and response_type_map are provided, the map takes precedence + (404, _RESPONSE_TYPE_MAP, "str", "ExampleException"), + (400, _RESPONSE_TYPE_MAP, "str", None), + # Even if the map is empty. + (404, {}, "str", None), + (404, {}, "ExampleException", None), + # If neither are provided, check there is no attempt to deserialize + (404, None, None, None), + ], + ) + def test_response_type_handling_of_exceptions( + self, + mocker, + response_code: int, + response_type_map, + response_type, + expected_type, + ): + resource_path = "/items" + method = "GET" + + expected_url = TEST_URL + resource_path + deserialize_mock = mocker.patch.object(ApiClient, "deserialize") + with requests_mock.Mocker() as m: + m.get( + expected_url, + status_code=response_code, + ) + with pytest.raises(ApiException) as excinfo: + _ = self._client.call_api( + resource_path, + method, + response_type=response_type, + response_type_map=response_type_map, + ) + + deserialize_mock.assert_called_once() + last_call_pos_args = deserialize_mock.call_args[0] + assert last_call_pos_args[1] == expected_type + class TestStaticMethods: """Test miscellaneous static methods on the ApiClient class"""