1
0
mirror of https://github.com/checktheroads/hyperglass synced 2024-05-11 05:55:08 +00:00
Files
checktheroads-hyperglass/hyperglass/external/_base.py

364 lines
12 KiB
Python
Raw Normal View History

"""Session handler for external http data sources."""
2020-04-18 23:18:50 -07:00
# Standard Library
import re
import json as _json
2020-04-19 00:23:56 -07:00
import socket
import typing as t
2020-04-18 23:18:50 -07:00
from json import JSONDecodeError
from socket import gaierror
# Third Party
import httpx
# Project
from hyperglass.log import log
2021-12-15 00:50:20 -07:00
from hyperglass.util import parse_exception, repr_from_attrs
2022-12-19 16:58:12 -05:00
from hyperglass.settings import Settings
2020-04-21 03:29:42 -07:00
from hyperglass.constants import __version__
from hyperglass.models.fields import JsonValue, HttpMethod, Primitives
from hyperglass.exceptions.private import ExternalError
2020-04-18 23:18:50 -07:00
if t.TYPE_CHECKING:
# Standard Library
from types import TracebackType
2020-04-18 23:18:50 -07:00
# Project
from hyperglass.exceptions._common import ErrorLevel
from hyperglass.models.config.logging import Http
D = t.TypeVar("D", bound=t.Dict)
def _prepare_dict(_dict: D) -> D:
2020-04-18 23:18:50 -07:00
return _json.loads(_json.dumps(_dict, default=str))
class BaseExternal:
"""Base session handler."""
def __init__(
2020-04-21 03:29:42 -07:00
self,
base_url: str,
config: t.Optional["Http"] = None,
uri_prefix: str = "",
uri_suffix: str = "",
verify_ssl: bool = True,
timeout: int = 10,
parse: bool = True,
) -> None:
2020-04-18 23:18:50 -07:00
"""Initialize connection instance."""
2020-06-06 01:24:53 -07:00
self.__name__ = getattr(self, "name", "BaseExternal")
self.name = self.__name__
2020-04-21 03:29:42 -07:00
self.config = config
2020-04-18 23:18:50 -07:00
self.base_url = base_url.strip("/")
self.uri_prefix = uri_prefix.strip("/")
self.uri_suffix = uri_suffix.strip("/")
self.verify_ssl = verify_ssl
self.timeout = timeout
2020-06-06 01:24:53 -07:00
self.parse = parse
2020-04-19 00:22:01 -07:00
2022-12-19 14:57:20 -05:00
context = httpx.create_ssl_context(verify=verify_ssl)
if Settings.ca_cert is not None:
context.load_verify_locations(cafile=str(Settings.ca_cert))
client_kwargs = {
2020-04-19 00:22:01 -07:00
"base_url": self.base_url,
"timeout": self.timeout,
2022-12-19 14:57:20 -05:00
"verify": context,
2020-04-19 00:22:01 -07:00
}
2022-12-19 14:57:20 -05:00
self._session = httpx.Client(**client_kwargs)
self._asession = httpx.AsyncClient(**client_kwargs)
2020-04-18 23:18:50 -07:00
@classmethod
def __init_subclass__(
cls: "BaseExternal", name: t.Optional[str] = None, **kwargs: t.Any
) -> None:
2020-04-18 23:18:50 -07:00
"""Set correct subclass name."""
super().__init_subclass__(**kwargs)
cls.name = name or cls.__name__
async def __aenter__(self: "BaseExternal") -> "BaseExternal":
2020-04-18 23:18:50 -07:00
"""Test connection on entry."""
2020-04-19 00:22:01 -07:00
available = await self._atest()
2020-04-18 23:18:50 -07:00
if available:
log.debug("Initialized session with {}", self.base_url)
return self
raise self._exception(f"Unable to create session to {self.name}")
2020-04-18 23:18:50 -07:00
async def __aexit__(
self: "BaseExternal",
exc_type: t.Optional[t.Type[BaseException]] = None,
exc_value: t.Optional[BaseException] = None,
traceback: t.Optional["TracebackType"] = None,
) -> True:
2020-04-18 23:18:50 -07:00
"""Close connection on exit."""
log.debug("Closing session with {}", self.base_url)
if exc_type is not None:
log.error(str(exc_value))
2020-04-19 00:22:01 -07:00
await self._asession.aclose()
if exc_value is not None:
raise exc_value
2020-04-18 23:18:50 -07:00
return True
def __enter__(self: "BaseExternal") -> "BaseExternal":
2020-04-19 00:23:56 -07:00
"""Test connection on entry."""
2020-04-19 00:22:01 -07:00
available = self._test()
if available:
log.debug("Initialized session with {}", self.base_url)
return self
raise self._exception(f"Unable to create session to {self.name}")
2020-04-19 00:22:01 -07:00
def __exit__(
self: "BaseExternal",
exc_type: t.Optional[t.Type[BaseException]] = None,
exc_value: t.Optional[BaseException] = None,
exc_traceback: t.Optional["TracebackType"] = None,
) -> bool:
2020-04-19 00:23:56 -07:00
"""Close connection on exit."""
2020-04-19 00:22:01 -07:00
if exc_type is not None:
log.error(str(exc_value))
2020-04-19 00:22:01 -07:00
self._session.close()
if exc_value is not None:
raise exc_value
return True
2020-04-19 00:22:01 -07:00
def __repr__(self: "BaseExternal") -> str:
2020-04-18 23:18:50 -07:00
"""Return user friendly representation of instance."""
2021-12-15 00:50:20 -07:00
return repr_from_attrs(self, ("name", "base_url", "config", "parse"))
2020-04-18 23:18:50 -07:00
def _exception(
self: "BaseExternal",
message: str,
exc: t.Optional[BaseException] = None,
level: "ErrorLevel" = "warning",
**kwargs: t.Any,
) -> ExternalError:
2020-04-18 23:18:50 -07:00
"""Add stringified exception to message if passed."""
if exc is not None:
message = f"{message!s}: {exc!s}"
2020-04-18 23:18:50 -07:00
return ExternalError(message=message, level=level, **kwargs)
2020-04-18 23:18:50 -07:00
def _parse_response(self: "BaseExternal", response: httpx.Response) -> t.Any:
2020-06-06 01:24:53 -07:00
if self.parse:
parsed = {}
try:
parsed = response.json()
except JSONDecodeError:
try:
parsed = _json.loads(response)
except (JSONDecodeError, TypeError):
log.error("Error parsing JSON for response {}", repr(response))
parsed = {"data": response.text}
else:
parsed = response
return parsed
def _test(self: "BaseExternal") -> bool:
2020-04-19 00:22:01 -07:00
"""Open a low-level connection to the base URL to ensure its port is open."""
log.debug("Testing connection to {}", self.base_url)
try:
# Parse out just the hostname from a URL string.
# E.g. `https://www.example.com` becomes `www.example.com`
2020-04-19 00:22:01 -07:00
test_host = re.sub(r"http(s)?\:\/\/", "", self.base_url)
# Create a generic socket object
test_socket = socket.socket()
# Try opening a low-level socket to make sure it's even
# listening on the port prior to trying to use it.
test_socket.connect((test_host, 443))
# Properly shutdown & close the socket.
test_socket.shutdown(1)
test_socket.close()
2020-04-19 00:22:01 -07:00
except gaierror as err:
# Raised if the target isn't listening on the port
raise self._exception(
f"{self.name!r} appears to be unreachable at {self.base_url!r}", err
) from None
2020-04-19 00:22:01 -07:00
return True
async def _atest(self: "BaseExternal") -> bool:
2020-04-18 23:18:50 -07:00
"""Open a low-level connection to the base URL to ensure its port is open."""
return self._test()
2020-04-18 23:18:50 -07:00
def _build_request(self: "BaseExternal", **kwargs: t.Any) -> t.Dict[str, t.Any]:
2020-04-19 00:23:56 -07:00
"""Process requests parameters into structure usable by http library."""
# Standard Library
2020-04-19 00:22:01 -07:00
from operator import itemgetter
2020-04-18 23:18:50 -07:00
supported_methods = ("GET", "POST", "PUT", "DELETE", "HEAD", "PATCH")
2021-09-12 15:09:24 -07:00
(method, endpoint, item, headers, params, data, timeout, response_required,) = itemgetter(
*kwargs.keys()
)(kwargs)
2020-04-19 00:22:01 -07:00
2020-04-18 23:18:50 -07:00
if method.upper() not in supported_methods:
raise self._exception(
2021-09-12 15:09:24 -07:00
f'Method must be one of {", ".join(supported_methods)}. ' f"Got: {str(method)}"
2020-04-18 23:18:50 -07:00
)
endpoint = "/".join(
i
for i in (
"",
self.uri_prefix.strip("/"),
endpoint.strip("/"),
self.uri_suffix.strip("/"),
item,
)
if i
)
request = {
"method": method,
"url": endpoint,
2020-04-21 03:29:42 -07:00
"headers": {"user-agent": f"hyperglass/{__version__}"},
2020-04-18 23:18:50 -07:00
}
2020-04-21 03:29:42 -07:00
if headers is not None:
request.update({"headers": headers})
2020-04-18 23:18:50 -07:00
if params is not None:
params = {str(k): str(v) for k, v in params.items() if v is not None}
request["params"] = params
if data is not None:
if not isinstance(data, dict):
raise self._exception(f"Data must be a dict, got: {str(data)}")
request["json"] = _prepare_dict(data)
if timeout is not None:
if not isinstance(timeout, int):
try:
timeout = int(timeout)
except TypeError:
2021-09-12 15:09:24 -07:00
raise self._exception(f"Timeout must be an int, got: {str(timeout)}")
2020-04-18 23:18:50 -07:00
request["timeout"] = timeout
log.debug("Constructed request parameters {}", request)
2020-04-19 00:22:01 -07:00
return request
async def _arequest( # noqa: C901
self: "BaseExternal",
method: HttpMethod,
endpoint: str,
item: t.Union[str, int, None] = None,
headers: t.Dict[str, str] = None,
params: t.Dict[str, JsonValue[Primitives]] = None,
data: t.Optional[t.Any] = None,
timeout: t.Optional[int] = None,
response_required: bool = False,
) -> t.Any:
2020-04-19 00:22:01 -07:00
"""Run HTTP POST operation."""
2020-04-21 03:29:42 -07:00
request = self._build_request(
2020-04-19 00:22:01 -07:00
method=method,
endpoint=endpoint,
item=item,
2020-04-21 03:29:42 -07:00
headers=None,
2020-04-19 00:22:01 -07:00
params=params,
data=data,
timeout=timeout,
response_required=response_required,
)
try:
response = await self._asession.request(**request)
if response.status_code not in range(200, 300):
status = httpx.codes(response.status_code)
2020-06-06 01:24:53 -07:00
error = self._parse_response(response)
2020-04-19 00:22:01 -07:00
raise self._exception(
f'{status.name.replace("_", " ")}: {error}', level="danger"
) from None
except httpx.HTTPError as http_err:
raise self._exception(parse_exception(http_err), level="danger") from None
2020-06-06 01:24:53 -07:00
return self._parse_response(response)
2020-04-19 00:22:01 -07:00
async def _aget(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="GET", endpoint=endpoint, **kwargs)
async def _apost(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="POST", endpoint=endpoint, **kwargs)
async def _aput(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="PUT", endpoint=endpoint, **kwargs)
async def _adelete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="DELETE", endpoint=endpoint, **kwargs)
async def _apatch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="PATCH", endpoint=endpoint, **kwargs)
async def _ahead(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return await self._arequest(method="HEAD", endpoint=endpoint, **kwargs)
def _request( # noqa: C901
self: "BaseExternal",
method: HttpMethod,
endpoint: str,
item: t.Union[str, int, None] = None,
headers: t.Dict[str, str] = None,
params: t.Dict[str, JsonValue[Primitives]] = None,
data: t.Optional[t.Any] = None,
timeout: t.Optional[int] = None,
response_required: bool = False,
) -> t.Any:
2020-04-19 00:22:01 -07:00
"""Run HTTP POST operation."""
2020-04-21 03:29:42 -07:00
request = self._build_request(
2020-04-19 00:22:01 -07:00
method=method,
endpoint=endpoint,
item=item,
2020-04-21 03:29:42 -07:00
headers=None,
2020-04-19 00:22:01 -07:00
params=params,
data=data,
timeout=timeout,
response_required=response_required,
)
2020-04-18 23:18:50 -07:00
try:
2020-04-19 00:22:01 -07:00
response = self._session.request(**request)
2020-04-18 23:18:50 -07:00
if response.status_code not in range(200, 300):
status = httpx.codes(response.status_code)
2020-06-06 01:24:53 -07:00
error = self._parse_response(response)
2020-04-18 23:18:50 -07:00
raise self._exception(
f'{status.name.replace("_", " ")}: {error}', level="danger"
) from None
except httpx.HTTPError as http_err:
raise self._exception(parse_exception(http_err), level="danger") from None
2020-06-06 01:24:53 -07:00
return self._parse_response(response)
2020-04-18 23:18:50 -07:00
def _get(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="GET", endpoint=endpoint, **kwargs)
2020-04-18 23:18:50 -07:00
def _post(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="POST", endpoint=endpoint, **kwargs)
2020-04-18 23:18:50 -07:00
def _put(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="PUT", endpoint=endpoint, **kwargs)
2020-04-18 23:18:50 -07:00
def _delete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="DELETE", endpoint=endpoint, **kwargs)
2020-04-18 23:18:50 -07:00
def _patch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="PATCH", endpoint=endpoint, **kwargs)
2020-04-18 23:18:50 -07:00
def _head(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any:
2020-04-19 00:22:01 -07:00
return self._request(method="HEAD", endpoint=endpoint, **kwargs)