mirror of
https://github.com/netinvent/npbackup.git
synced 2025-02-24 22:44:04 +08:00
Add inline copy of ofunctions.requestor
This commit is contained in:
parent
7714220238
commit
dcf2293ec0
1 changed files with 434 additions and 0 deletions
434
npbackup/upgrade_client/requestor.py
Normal file
434
npbackup/upgrade_client/requestor.py
Normal file
|
@ -0,0 +1,434 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# This file is part of npbackup
|
||||
|
||||
__intname__ = "ofunctions.requestor"
|
||||
__author__ = "Orsiris de Jong"
|
||||
__copyright__ = "Copyright (C) 2014-2023 NetInvent"
|
||||
__license__ = "BSD-3-Clause"
|
||||
__build__ = "2022072201"
|
||||
|
||||
|
||||
from typing import List, Optional, Any, Union
|
||||
from logging import getLogger
|
||||
import json
|
||||
import requests
|
||||
|
||||
|
||||
logger = getLogger(__intname__)
|
||||
|
||||
|
||||
class Requestor:
|
||||
"""
|
||||
A class that handles JSON APIs elegantly, with server fallback, ACLS and error control
|
||||
|
||||
data_model: high level json api function
|
||||
requestor: standard requests
|
||||
get_raw: low level binary download
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
servers: List[str],
|
||||
username: str = None,
|
||||
password: str = None,
|
||||
cert_verify: bool = True,
|
||||
):
|
||||
self.api_session = None
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.cert_verify = cert_verify
|
||||
self._endpoint = None
|
||||
|
||||
self._app_name = "ofunctions-requestor-app"
|
||||
self._user_agent = "ofunctions-requestor-ua"
|
||||
|
||||
self._action_list = ["create", "read", "update", "delete", "exists"]
|
||||
self._allowed_models = []
|
||||
self._acls = {}
|
||||
"""
|
||||
ACLS would look like a dict giving each allowed action for each model, eg:
|
||||
{
|
||||
'users': 'create', 'read', 'update', 'delete', 'exists',
|
||||
'items': 'read', 'exists'
|
||||
}
|
||||
"""
|
||||
self._action_requests_equiv = {
|
||||
"create": "post",
|
||||
"read": "get",
|
||||
"update": "put",
|
||||
"delete": "delete",
|
||||
"exists": "get",
|
||||
}
|
||||
|
||||
# Headers need to be set to Accept: application/json or else Laravel will send HTML as return
|
||||
# Do not set Content-Type or else uploads will fail
|
||||
self._headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "deflate, gzip",
|
||||
"User-Agent": self._user_agent,
|
||||
"Referer": self._app_name,
|
||||
}
|
||||
|
||||
self._proxy_dict = {}
|
||||
|
||||
self.servers = []
|
||||
# servers can be multiple servers for failover
|
||||
if isinstance(servers, list):
|
||||
self.servers = servers
|
||||
elif servers is not None:
|
||||
for server in servers.split(","):
|
||||
# Remove trailing & ending spaces
|
||||
# Make sure server ends with '/'
|
||||
self.servers.append(server.strip().rstrip("/") + "/")
|
||||
self.connected_server = None
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
return self._app_name
|
||||
|
||||
@app_name.setter
|
||||
def app_name(self, value: str):
|
||||
if isinstance(value, str):
|
||||
self._app_name = value.strip()
|
||||
else:
|
||||
raise ValueError("Bogus app name")
|
||||
|
||||
@property
|
||||
def user_agent(self):
|
||||
return self._user_agent
|
||||
|
||||
@user_agent.setter
|
||||
def user_agent(self, value: str):
|
||||
if isinstance(value, str):
|
||||
self._user_agent = value.strip()
|
||||
else:
|
||||
raise ValueError("Bogus user agent")
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._headers
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
pass
|
||||
|
||||
@header.setter
|
||||
def header(self, value: dict):
|
||||
if isinstance(value, dict):
|
||||
self._headers = {**self._headers, **value}
|
||||
if self.api_session:
|
||||
self.api_session.headers = self._headers
|
||||
else:
|
||||
raise ValueError("Bogus header given")
|
||||
|
||||
@headers.setter
|
||||
def headers(self, value: dict):
|
||||
if isinstance(value, dict):
|
||||
self._headers = value
|
||||
if self.api_session:
|
||||
self.api_session.headers = self._headers
|
||||
else:
|
||||
raise ValueError("Bogus header given")
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
return self._endpoint
|
||||
|
||||
@endpoint.setter
|
||||
def endpoint(self, value: str):
|
||||
if isinstance(value, str):
|
||||
self._endpoint = "/" + value.strip().lstrip("/")
|
||||
|
||||
@property
|
||||
def allowed_models(self):
|
||||
return self._allowed_models
|
||||
|
||||
@allowed_models.setter
|
||||
def allowed_models(self, value: List[str]):
|
||||
if isinstance(value, list):
|
||||
self._allowed_models = value
|
||||
else:
|
||||
raise ValueError("Bogus allowed models given")
|
||||
|
||||
@property
|
||||
def acls(self):
|
||||
return self._acls
|
||||
|
||||
@acls.setter
|
||||
def acls(self, value: dict):
|
||||
if isinstance(value, list):
|
||||
for key, val in value:
|
||||
if key not in self.allowed_models:
|
||||
raise ValueError("ACL for non existant model given")
|
||||
if val not in self._action_list:
|
||||
raise ValueError("Not a valid action")
|
||||
|
||||
self._acls = value
|
||||
else:
|
||||
raise ValueError("Bogus acls given")
|
||||
|
||||
@property
|
||||
def action_list(self) -> List[str]:
|
||||
return self._action_list
|
||||
|
||||
@property
|
||||
def proxy(self) -> dict:
|
||||
return self._proxy_dict
|
||||
|
||||
@proxy.setter
|
||||
def proxy(self, value: str):
|
||||
if isinstance(value, str) and (
|
||||
value.startswith("http://") or value.startswith("https://")
|
||||
):
|
||||
if value.startswith("http"):
|
||||
self._proxy_dict = {"http": value.strip("http://")}
|
||||
elif value.startswith("https"):
|
||||
self._proxy_dict = {"https": value.strip("https://")}
|
||||
else:
|
||||
raise ValueError("Bogus proxy given")
|
||||
|
||||
def _create_session(self, uri: str = "", authenticated: bool = False) -> bool:
|
||||
"""Needed for api calls that don't require api authentication"""
|
||||
try:
|
||||
api_session = requests.Session()
|
||||
# Remove accept: application/json from headers since it will fail on non API calls
|
||||
headers = self.headers
|
||||
headers["Accept"] = ""
|
||||
if authenticated:
|
||||
api_session.auth = (self.username, self.password)
|
||||
result = api_session.get(uri, verify=self.cert_verify, headers=headers)
|
||||
try:
|
||||
status_code = result.status_code
|
||||
except AttributeError:
|
||||
logger.error("Server did not return a status code")
|
||||
status_code = None
|
||||
|
||||
try:
|
||||
text = result.text
|
||||
except AttributeError:
|
||||
logger.error("Server did not return any data")
|
||||
text = None
|
||||
if status_code == 200:
|
||||
self.api_session = api_session
|
||||
if authenticated:
|
||||
# This has to be changed depending on the API
|
||||
if text.lower().startswith("token"):
|
||||
token = text.replace("Token", "").strip()
|
||||
self.header = {"Authorization": "Bearer {}".format(token)}
|
||||
return True
|
||||
|
||||
logger.error("Cannot establish a session to server.")
|
||||
logger.warning("Server return code: {}".format(status_code))
|
||||
try:
|
||||
logger.debug(
|
||||
"Error:\n{}".format(text.encode("utf-8", errors="backslashreplace"))
|
||||
)
|
||||
except:
|
||||
pass
|
||||
except requests.exceptions.SSLError:
|
||||
logger.error(
|
||||
"Cannot establish a session: SSL/TLS error. Are your server & client certificates valid ?"
|
||||
)
|
||||
logger.info("Trace:", exc_info=True)
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error(
|
||||
"Cannot establish a session. Looks like we cannot reach the server."
|
||||
)
|
||||
logger.info("Trace:", exc_info=True)
|
||||
except Exception as exc: # pylint: disable=W0703,broad-except
|
||||
logger.error("Cannot establish a session, unknown reason: %s", exc)
|
||||
logger.info("Trace:", exc_info=True)
|
||||
return False
|
||||
|
||||
def create_session(self, endpoint: str = None, authenticated: bool = False) -> bool:
|
||||
"""
|
||||
Tries every server in server list until one can be reached, and sets server_api
|
||||
:return:
|
||||
"""
|
||||
auth_endpoint = None
|
||||
for server in self.servers:
|
||||
if endpoint:
|
||||
auth_endpoint = server + endpoint.strip("/")
|
||||
elif self._endpoint:
|
||||
auth_endpoint = server + self._endpoint.strip("/")
|
||||
else:
|
||||
auth_endpoint = server
|
||||
if (
|
||||
not self._create_session(auth_endpoint, authenticated)
|
||||
and len(self.servers) > 1
|
||||
):
|
||||
logger.info("Contacting auth server failed. Trying fallback server.")
|
||||
else:
|
||||
self.connected_server = server
|
||||
return True
|
||||
return False
|
||||
|
||||
def _base_requestor(
|
||||
self, endpoint: str = None, action: str = "read", data: Any = None
|
||||
) -> requests.Request:
|
||||
"""
|
||||
simple request function that does handle all exceptions and will return a requeusts.Request object or False
|
||||
"""
|
||||
if not self.api_session:
|
||||
logger.error("Cannot operate without proper session.")
|
||||
return None
|
||||
|
||||
if not self.connected_server:
|
||||
logger.error(
|
||||
"Currently not connected to any server. Do we have an open session ?"
|
||||
)
|
||||
return False
|
||||
|
||||
if endpoint:
|
||||
url = self.connected_server + endpoint.strip("/")
|
||||
else:
|
||||
url = self.connected_server
|
||||
try:
|
||||
if action in ["update", "create"]:
|
||||
result = getattr(self.api_session, self._action_requests_equiv[action])(
|
||||
url,
|
||||
headers=self.headers,
|
||||
data=data,
|
||||
proxies=self._proxy_dict,
|
||||
verify=self.cert_verify,
|
||||
)
|
||||
else:
|
||||
result = getattr(self.api_session, self._action_requests_equiv[action])(
|
||||
url,
|
||||
headers=self.headers,
|
||||
proxies=self._proxy_dict,
|
||||
verify=self.cert_verify,
|
||||
)
|
||||
status_code = result.status_code
|
||||
if status_code in [200, 201, 202]:
|
||||
if status_code == 200:
|
||||
logger.debug(f"Succesful operation {action}:{endpoint}")
|
||||
elif status_code == 201:
|
||||
logger.debug(f"Created operation {action}:{endpoint}")
|
||||
elif status_code == 202:
|
||||
logger.debug(f"Accepted operation {action}:{endpoint}")
|
||||
if action == "exists":
|
||||
return True
|
||||
return result
|
||||
else:
|
||||
if (status_code in [400, 404]) and action == "exists":
|
||||
logger.debug(f"Exists operation{action}:{endpoint}: No.")
|
||||
elif status_code == 401:
|
||||
logger.error(f"Server denied operation for {action}:{endpoint}")
|
||||
elif status_code == 404:
|
||||
logger.debug(f"Server did not find {action}:{endpoint}")
|
||||
else:
|
||||
logger.error(f"Failed operation {action}:{endpoint}")
|
||||
logger.error(f"Server return code: {status_code}.")
|
||||
try:
|
||||
logger.error(
|
||||
f'Error:\n{result.text.encode("utf-8", errors="backslashreplace")}'
|
||||
)
|
||||
except Exception: # pylint: disable=W0703,broad-except
|
||||
logger.error("No other info given by server.")
|
||||
return False
|
||||
except requests.exceptions.SSLError:
|
||||
logger.error(
|
||||
"Cannot establish a session: SSL/TLS error. Are your server & client certificates valid ?"
|
||||
)
|
||||
logger.error("Trace:", exc_info=True)
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error(
|
||||
"Cannot establish a session. Looks like we cannot reach the server."
|
||||
)
|
||||
logger.error("Trace:", exc_info=True)
|
||||
except Exception as exc: # pylint: disable=W0703,broad-except
|
||||
logger.error("Cannot establish a session, unknown reason: %s", exc)
|
||||
logger.error("Trace:", exc_info=True)
|
||||
return False
|
||||
|
||||
def requestor(
|
||||
self,
|
||||
endpoint: str = None,
|
||||
action: str = "read",
|
||||
data: Any = None,
|
||||
json_output=False,
|
||||
raw=False,
|
||||
) -> Union[dict, bytes, bool, str]:
|
||||
"""
|
||||
simple request function that does handle all exceptions and will return content or False
|
||||
"""
|
||||
if action not in self.action_list:
|
||||
logger.error("Unknown action %s", action)
|
||||
|
||||
result = self._base_requestor(endpoint, action, data)
|
||||
if not result:
|
||||
return False
|
||||
|
||||
if json_output:
|
||||
try:
|
||||
return json.loads(result.text)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Cannot decode json output: {}".format(exc))
|
||||
return None
|
||||
if raw:
|
||||
return result.content
|
||||
return result.text
|
||||
|
||||
def get_raw(self, endpoint: str) -> Union[bytes, bool]:
|
||||
"""
|
||||
Shorthand essentially used to download binary files
|
||||
"""
|
||||
result = self._base_requestor(endpoint, action="read")
|
||||
if result:
|
||||
return result.content
|
||||
return result
|
||||
|
||||
def data_model(
|
||||
self,
|
||||
model: str = None,
|
||||
id_record: Optional[Union[int, str]] = None,
|
||||
action: str = "read",
|
||||
data: Any = None,
|
||||
json_output: bool = True,
|
||||
) -> Optional[Union[bool, str, dict]]:
|
||||
"""
|
||||
CRUD(E) model handler for APIs
|
||||
|
||||
Example: update users in /users
|
||||
payload = {
|
||||
'username': 'Hello',
|
||||
'password': 'somepass'
|
||||
}
|
||||
res = requestor.handle_data_model(model='users', action='update', id=34, data=payload)
|
||||
This will call a PUT <server_uri>/users/34
|
||||
"""
|
||||
if self.allowed_models and model not in self.allowed_models:
|
||||
logger.error("Model %s is not allowed", model)
|
||||
|
||||
if action not in self.action_list:
|
||||
logger.error("Unknown action %s", action)
|
||||
|
||||
if self.allowed_models and self.acls:
|
||||
if action not in self.acls[model]:
|
||||
logger.error("ACLS don't allow action %s for model %s", action, model)
|
||||
|
||||
# Sanitize model and id_record
|
||||
if model:
|
||||
model = model.strip().strip("/")
|
||||
|
||||
if action == "create":
|
||||
id_record = None
|
||||
if id_record:
|
||||
id_record = id_record.strip().strip("/")
|
||||
|
||||
if isinstance(id_record, str) and id_record.startswith("#"):
|
||||
raise ValueError(
|
||||
"id may not start with [#] sign since it is reserved for pagination."
|
||||
)
|
||||
|
||||
if action != "create" and model and id_record:
|
||||
# Action is read, exists, update or delete
|
||||
model_endpoint = "{}/{}".format(model, id_record)
|
||||
else:
|
||||
model_endpoint = model
|
||||
|
||||
result = self.requestor(model_endpoint, action, data, json_output)
|
||||
return result
|
Loading…
Reference in a new issue