diff --git a/src/bos/common/clients/endpoints/__init__.py b/src/bos/common/clients/endpoints/__init__.py new file mode 100644 index 00000000..5ea4171a --- /dev/null +++ b/src/bos/common/clients/endpoints/__init__.py @@ -0,0 +1,30 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 .base_endpoint import BaseEndpoint +from .base_generic_endpoint import BaseGenericEndpoint, RequestErrorHandler +from .base_raw_endpoint import BaseRawEndpoint +from .defs import JsonData, RequestData, RequestsMethod +from .exceptions import ApiResponseError +from .response_data import ResponseData diff --git a/src/bos/common/clients/endpoints/base_endpoint.py b/src/bos/common/clients/endpoints/base_endpoint.py new file mode 100644 index 00000000..21517fb5 --- /dev/null +++ b/src/bos/common/clients/endpoints/base_endpoint.py @@ -0,0 +1,41 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 abc import ABC + +import requests + +from .base_generic_endpoint import BaseGenericEndpoint +from .defs import JsonData +from .response_data import ResponseData + + +class BaseEndpoint(BaseGenericEndpoint[JsonData], ABC): + """ + This base class provides generic access to an API where the only part of the response + that is returned is the body. + """ + + @classmethod + def format_response(cls, response: requests.Response) -> JsonData: + return ResponseData.from_response(response).body diff --git a/src/bos/common/clients/endpoints/base_generic_endpoint.py b/src/bos/common/clients/endpoints/base_generic_endpoint.py new file mode 100644 index 00000000..b68e3705 --- /dev/null +++ b/src/bos/common/clients/endpoints/base_generic_endpoint.py @@ -0,0 +1,119 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 abc import ABC, abstractmethod +import logging +from typing import Generic, TypeVar + +import requests + +from .defs import RequestData, RequestsMethod +from .exceptions import ApiResponseError +from .request_error_handler import BaseRequestErrorHandler, RequestErrorHandler + +LOGGER = logging.getLogger(__name__) + +RequestReturnT = TypeVar('RequestReturnT') + + +class BaseGenericEndpoint(ABC, Generic[RequestReturnT]): + """ + This base class provides generic access to an API endpoint. + RequestReturnT represents the type of data this API will return. + Most often this will be the Json data from the response body, but in some + cases (like with BSS), we are after something else. + + Exceptions are handled by a separate class, since different API clients + may want to handle these differently. + """ + BASE_ENDPOINT: str = '' + ENDPOINT: str = '' + error_handler: BaseRequestErrorHandler = RequestErrorHandler + + def __init__(self, session: requests.Session): + super().__init__() + self.session = session + + @classmethod + @abstractmethod + def format_response(cls, response: requests.Response) -> RequestReturnT: + ... + + @classmethod + def base_url(cls) -> str: + return f"{cls.BASE_ENDPOINT}/{cls.ENDPOINT}" + + @classmethod + def url(cls, uri: str) -> str: + base_url = cls.base_url() + if not uri: + return base_url + if uri[0] == '/' or base_url[-1] == '/': + return f"{base_url}{uri}" + return f"{base_url}/{uri}" + + def request(self, + method: RequestsMethod, + /, + *, + uri: str = "", + **kwargs) -> RequestReturnT: + url = self.url(uri) + LOGGER.debug("%s %s (kwargs=%s)", method.__name__.upper(), url, kwargs) + try: + return self._request(method, url, **kwargs) + except Exception as err: + self.error_handler.handle_exception( + err, + RequestData(method_name=method.__name__.upper(), + url=url, + request_options=kwargs)) + + @classmethod + def _request(cls, method: RequestsMethod, url: str, /, + **kwargs) -> RequestReturnT: + """Make API request""" + with method(url, **kwargs) as response: + if not response.ok: + raise ApiResponseError(response=response) + return cls.format_response(response) + + def delete(self, **kwargs) -> RequestReturnT: + """Delete request""" + return self.request(self.session.delete, **kwargs) + + def get(self, **kwargs) -> RequestReturnT: + """Get request""" + return self.request(self.session.get, **kwargs) + + def patch(self, **kwargs) -> RequestReturnT: + """Patch request""" + return self.request(self.session.patch, **kwargs) + + def post(self, **kwargs) -> RequestReturnT: + """Post request""" + return self.request(self.session.post, **kwargs) + + def put(self, **kwargs) -> RequestReturnT: + """Put request""" + return self.request(self.session.put, **kwargs) diff --git a/src/bos/common/clients/endpoints/base_raw_endpoint.py b/src/bos/common/clients/endpoints/base_raw_endpoint.py new file mode 100644 index 00000000..b9149ff6 --- /dev/null +++ b/src/bos/common/clients/endpoints/base_raw_endpoint.py @@ -0,0 +1,41 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 abc import ABC + +import requests + +from .base_generic_endpoint import BaseGenericEndpoint +from .response_data import ResponseData + + +class BaseRawEndpoint(BaseGenericEndpoint[ResponseData], ABC): + """ + This base class provides generic access to an API. + In this case, an assortment of response data is returned up, rather than + just the response body. + """ + + @classmethod + def format_response(cls, response: requests.Response) -> ResponseData: + return ResponseData.from_response(response) diff --git a/src/bos/common/clients/endpoints/defs.py b/src/bos/common/clients/endpoints/defs.py new file mode 100644 index 00000000..8b74cd27 --- /dev/null +++ b/src/bos/common/clients/endpoints/defs.py @@ -0,0 +1,42 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 typing import Any, Callable, ContextManager, NamedTuple + +import requests + +type JsonData = bool | str | None | int | float | list[JsonData] | dict[str, JsonData] +type JsonDict = dict[str, JsonData] +type JsonList = list[JsonData] + +type RequestsMethod = Callable[..., ContextManager[requests.Response]] + +class RequestData(NamedTuple): + """ + This class encapsulates data about an API request. + It is passed into the exception handler, so that it is able to + include information about the request in its logic and error messages. + """ + method_name: str + url: str + request_options: dict[str, Any] diff --git a/src/bos/common/clients/endpoints/exceptions.py b/src/bos/common/clients/endpoints/exceptions.py new file mode 100644 index 00000000..8b57247c --- /dev/null +++ b/src/bos/common/clients/endpoints/exceptions.py @@ -0,0 +1,34 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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. +# +import requests + +from .response_data import ResponseData + + +class ApiResponseError(Exception): + """Raised when API response has non-ok status""" + + def __init__(self, *args, response: requests.Response, **kwargs): + super().__init__(*args, **kwargs) + self.response_data = ResponseData.from_response(response) diff --git a/src/bos/common/clients/endpoints/request_error_handler.py b/src/bos/common/clients/endpoints/request_error_handler.py new file mode 100644 index 00000000..4de38993 --- /dev/null +++ b/src/bos/common/clients/endpoints/request_error_handler.py @@ -0,0 +1,116 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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 abc import ABC, abstractmethod +from json import JSONDecodeError +import logging +from typing import NoReturn + +from requests.exceptions import HTTPError +from requests.exceptions import ConnectionError as RequestsConnectionError +from urllib3.exceptions import MaxRetryError + +from bos.common.utils import compact_response_text, exc_type_msg + +from .defs import RequestData, RequestsMethod +from .exceptions import ApiResponseError + +LOGGER = logging.getLogger(__name__) + + +class BaseRequestErrorHandler(ABC): + """ + The abstract base class for request error handlers that will be used by an API endpoint. + """ + @classmethod + @abstractmethod + def handle_exception(cls, err: Exception, + request_data: RequestData) -> NoReturn: + ... + + +class RequestErrorHandler(BaseRequestErrorHandler): + """ + The default request error handler used by API endpoints. + """ + @classmethod + def handle_api_response_error(cls, err: ApiResponseError, + request_data: RequestData) -> NoReturn: + msg = (f"Non-2XX response ({err.response.status_code}) to " + f"{request_data.method_name} {request_data.url}; " + f"{err.response.reason} " + f"{compact_response_text(err.response.text)}") + LOGGER.error(msg) + raise ApiResponseError(msg, response=err.response) from err + + @classmethod + def handle_connection_error(cls, err: RequestsConnectionError, + request_data: RequestData) -> NoReturn: + LOGGER.error("%s %s: Unable to connect: %s", request_data.method_name, + request_data.url, exc_type_msg(err)) + raise err + + @classmethod + def handle_http_error(cls, err: HTTPError, + request_data: RequestData) -> NoReturn: + LOGGER.error("%s %s: Unexpected response: %s", + request_data.method_name, request_data.url, + exc_type_msg(err)) + raise err + + @classmethod + def handle_json_decode_error(cls, err: JSONDecodeError, + request_data: RequestData) -> NoReturn: + LOGGER.error("%s %s: Non-JSON response: %s", request_data.method_name, + request_data.url, exc_type_msg(err)) + raise err + + @classmethod + def handle_max_retry_error(cls, err: MaxRetryError, + request_data: RequestData) -> NoReturn: + LOGGER.error("%s %s: Request failed after retries: %s", + request_data.method_name, request_data.url, + exc_type_msg(err)) + raise err + + @classmethod + def default(cls, err: Exception, request_data: RequestData) -> NoReturn: + LOGGER.error("%s %s: Unexpected exception: %s", + request_data.method_name, request_data.url, + exc_type_msg(err)) + raise err + + @classmethod + def handle_exception(cls, err: Exception, + request_data: RequestData) -> NoReturn: + if isinstance(err, ApiResponseError): + cls.handle_api_response_error(err, request_data) + if isinstance(err, RequestsConnectionError): + cls.handle_connection_error(err, request_data) + if isinstance(err, HTTPError): + cls.handle_http_error(err, request_data) + if isinstance(err, JSONDecodeError): + cls.handle_json_decode_error(err, request_data) + if isinstance(err, MaxRetryError): + cls.handle_max_retry_error(err, request_data) + cls.default(err, request_data) diff --git a/src/bos/common/clients/endpoints/response_data.py b/src/bos/common/clients/endpoints/response_data.py new file mode 100644 index 00000000..d7577f64 --- /dev/null +++ b/src/bos/common/clients/endpoints/response_data.py @@ -0,0 +1,53 @@ +# +# MIT License +# +# (C) Copyright 2021-2024 Hewlett Packard Enterprise Development LP +# +# 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. +# +import json +from typing import NamedTuple, Self + +import requests + +from .defs import JsonData, JsonDict + + +class ResponseData(NamedTuple): + """ + Encapsulates data from a response to an API request. This allows the + response itself to be cleaned up when its context manager exits. + """ + headers: JsonDict + ok: bool + reason: str + status_code: int + text: bytes | None + + @property + def body(self) -> JsonData: + return json.loads(self.text) if self.text else None + + @classmethod + def from_response(cls, resp: requests.Response) -> Self: + return cls(headers=resp.headers, + ok=resp.ok, + reason=resp.reason, + status_code=resp.status_code, + text=resp.text)