py.lib
2022-08-05
Child:483727ff89c4
py.lib/webapp/bottle_urils.py
+ Модуль работы с датаклассами и их наполнения из ORM + Утилиты Bottle + Утилиты JWT + Помошник в парсинге конфигурационных файлов
1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/webapp/bottle_urils.py Fri Aug 05 23:58:12 2022 +0300 1.3 @@ -0,0 +1,325 @@ 1.4 +# coding: utf-8 1.5 +"""\ 1.6 +Набор инструментов, для более удобного взаимодействия с фреймворком ``bottle`` 1.7 +""" 1.8 + 1.9 +import bottle 1.10 +import json 1.11 +from typing import Union, List, Optional, Tuple, Any 1.12 +from datetime import datetime 1.13 +from hashlib import sha512 1.14 +from dataclasses import is_dataclass, asdict 1.15 + 1.16 + 1.17 +VARIABLE_PREFIX = 'ru.a0fs.app' # Префикс переменных и иных структур, которые сохраняются во внутренних 1.18 + # структурах ``bottle`` 1.19 +IP_HEADER = 'X-Real-IP' # Заголовок, в котором реверс-прокси хранит реальный IP клиента. 1.20 +ID_HEADER = 'X-Req-ID' # Заголовок запроса, в котором может храниться уникальный ID запроса, 1.21 + # выставленного реверс-прокси. 1.22 +CONN_ID_HEADER = 'X-Conn-ID' # Идентификатор текущего соединения. 1.23 + 1.24 + 1.25 +class Cookie(object): 1.26 + """\ 1.27 + Класс хранящий ``cookie`` и способный их устанавливать в объекты http-ответов ``bottle`` 1.28 + """ 1.29 + 1.30 + def __init__(self, name: str, value: str, 1.31 + max_age: int = None, expires: Union[int, datetime] = None, path: str = None, 1.32 + secure: bool = True, httponly: bool = True, 1.33 + samesite: bool = False, domain: str = None): 1.34 + """\ 1.35 + :param name: Имя ``cookie`` 1.36 + :param value: Значение ``cookie`` 1.37 + :param max_age: Время жизни ``cookie`` в секундах. 1.38 + :param expires: Значение времени, когда cookie будет удалена, задаётся в виде ``unix timestamp (int)`` или 1.39 + ``datetime`` 1.40 + :param path: Префикс пути поиска ресурса на данном сайте, для которого следует отправлять данное ``cookie`` 1.41 + :param secure: Отправлять ``cookie`` только по шифрованным каналам связи 1.42 + :param httponly: Сделать ``cookie`` не доступной для ``JavaScript`` 1.43 + :param samesite: Не отправлять данную cookie, если запрос пришёл не с того же сайта (анализируется заголовок 1.44 + referer) 1.45 + :param domain: Имя домена в рамках которого выставляется cookie. В современных браузерах может и глючить 1.46 + """ 1.47 + 1.48 + self.name = name 1.49 + self.value = value 1.50 + self.max_age = max_age 1.51 + self.expires = expires 1.52 + self.path = path 1.53 + self.secure = secure 1.54 + self.httponly = httponly 1.55 + self.samesite = samesite 1.56 + self.domain = domain 1.57 + 1.58 + def response_add(self, resp: bottle.BaseResponse): 1.59 + res = { 1.60 + 'name': self.name, 1.61 + 'value': self.value, 1.62 + 'secure': self.secure, 1.63 + 'httponly': self.httponly, 1.64 + } 1.65 + 1.66 + if self.samesite: 1.67 + res['samesite'] = 'strict' 1.68 + 1.69 + for k in ('max_age', 'expires', 'path', 'domain'): 1.70 + if getattr(self, k) is not None: 1.71 + res[k] = getattr(self, k) 1.72 + 1.73 + resp.set_cookie(**res) 1.74 + 1.75 + 1.76 +def get_client_ip() -> str: 1.77 + """\ 1.78 + Получение реального адреса клиента 1.79 + """ 1.80 + 1.81 + ip = bottle.request.get_header(IP_HEADER) 1.82 + if ip: 1.83 + return ip 1.84 + 1.85 + else: 1.86 + return bottle.request.remote_addr 1.87 + 1.88 + 1.89 +def get_session_fingerprint(*add_params) -> str: 1.90 + """\ 1.91 + Конструируем некий отпечаток пользовательской сессии. 1.92 + 1.93 + В качестве параметра принимается всё, что должно участвовать в создании отпечатка. 1.94 + Это всё превращается в строки и подмешивается в хэш 1.95 + """ 1.96 + 1.97 + ua = bottle.request.get_header('user-agent') 1.98 + add_params_mixin = ':'.join(map(str, add_params)) 1.99 + 1.100 + return sha512(f'{ua}:{get_client_ip()}:{add_params_mixin}'.encode('utf-8')).hexdigest().lower() 1.101 + 1.102 + 1.103 +def variable_prep(value, new_value_type: type = str, postprocess: bool = True) -> Any: 1.104 + """\ 1.105 + Более интеллектуальная процедура преобразования базовых типов 1.106 + 1.107 + :param value: Значение, которое необходимо преобразовать 1.108 + :param new_value_type: Тип в который необходимо преобразовать значение 1.109 + :param postprocess: Применять ли последующую обработку полученного результата 1.110 + :returns: Значение ``value`` преобразованное к типу ``value_type`` 1.111 + """ 1.112 + 1.113 + if value is None: 1.114 + return value 1.115 + 1.116 + if issubclass(new_value_type, bool): 1.117 + if isinstance(value, str): 1.118 + if value.lower() in ('on', 'yes', '1', 'true',): 1.119 + return True 1.120 + elif value.lower() in ('off', 'no', '0', 'false',): 1.121 + return False 1.122 + else: 1.123 + raise ValueError(f'Unknown method translate "{value}" to "{new_value_type.__name__}"') 1.124 + 1.125 + res = new_value_type(value) 1.126 + 1.127 + if isinstance(res, str) and postprocess: 1.128 + res = res.strip() 1.129 + 1.130 + return res 1.131 + 1.132 + 1.133 +def get_variable(name: str, default=None): 1.134 + """\ 1.135 + Возвращает значения из хранилища контекста запроса bottle 1.136 + """ 1.137 + 1.138 + _name = f'{VARIABLE_PREFIX}.{name}' 1.139 + if _name in bottle.request.environ: 1.140 + return bottle.request.environ[_name] 1.141 + 1.142 + else: 1.143 + return default 1.144 + 1.145 + 1.146 +def set_variable(name: str, value): 1.147 + """\ 1.148 + Устанавливает значения в хранилище контекста запроса bottle 1.149 + """ 1.150 + 1.151 + bottle.request.environ[f'{VARIABLE_PREFIX}.{name}'] = value 1.152 + 1.153 + 1.154 +def make_response( 1.155 + data=None, status=200, cookies: Optional[List[Cookie]] = None 1.156 +) -> bottle.HTTPResponse: 1.157 + """\ 1.158 + Формирует ``API`` ответ. 1.159 + """ 1.160 + 1.161 + if data is None: 1.162 + data = {} 1.163 + 1.164 + res = bottle.HTTPResponse(body=json.dumps(json_type_sanitizer(data)), status=status) 1.165 + res.content_type = 'application/json' 1.166 + 1.167 + if cookies is not None: 1.168 + for cookie in cookies: 1.169 + cookie.response_add(res) 1.170 + 1.171 + return res 1.172 + 1.173 + 1.174 +def get_request_json() -> Optional[dict]: 1.175 + """\ 1.176 + Если в запросе, который мы обрабатываем в текущий момент, использовалась посылка данных JSON, получить их. 1.177 + """ 1.178 + 1.179 + res = bottle.request.json 1.180 + if res is None: 1.181 + raise ValueError('Не обнаружено JSON в запросе') 1.182 + 1.183 + return res 1.184 + 1.185 + 1.186 +def get_cookie(name: str) -> Optional[str]: 1.187 + """\ 1.188 + Получить значение ``cookie`` с заданным именем из текущего запроса. 1.189 + """ 1.190 + 1.191 + return bottle.request.get_cookie(name) 1.192 + 1.193 + 1.194 +def get_param(name: str, param_type: Union[type, str] = str, 1.195 + default=None, postprocess: bool = True, param_source: str = None): 1.196 + """\ 1.197 + Получить из обрабатываемого на данный момент запроса значения установленного параметра. Обрабатываются как 1.198 + ``GET``, так и ``POST`` параметры, если иное не указано конкретно. 1.199 + 1.200 + :param name: Имя параметра 1.201 + :param param_type: Тип, в который нужно преобразовать полученный параметр 1.202 + :param default: Значение, отдаваемое, если параметр не установлен в запросе 1.203 + :param postprocess: Производить ли постобработку параметра 1.204 + :param param_source: Источник параметра, [``get``, ``post``] 1.205 + :returns Полученное из запроса значение 1.206 + """ 1.207 + 1.208 + if param_source is None: 1.209 + res = bottle.request.params.getunicode(name) 1.210 + elif param_source.lower() == 'get': 1.211 + res = bottle.request.GET.getunicode(name) 1.212 + elif param_source.lower() == 'post': 1.213 + res = bottle.request.POST.getunicode(name) 1.214 + else: 1.215 + raise ValueError(f'Unknown parameter source "{param_source}" for "{name}"') 1.216 + 1.217 + if res is None: 1.218 + res = default 1.219 + 1.220 + if isinstance(param_type, str): 1.221 + if param_type.lower() == 'json': 1.222 + if not res: 1.223 + return None 1.224 + 1.225 + else: 1.226 + try: 1.227 + return json.loads(res) 1.228 + 1.229 + except json.JSONDecodeError as e: 1.230 + raise ValueError(f'Unable to get JSON from from parameter "{name}": param="{res}" error="{e}"') 1.231 + else: 1.232 + try: 1.233 + return variable_prep(res, new_value_type=param_type, postprocess=postprocess) 1.234 + 1.235 + except (TypeError, ValueError) as e: 1.236 + raise ValueError(f'Error parsing parameter "{name}": {e}') 1.237 + 1.238 + 1.239 +def dict_filter(data: dict, need_keys: Union[List[str], Tuple[str]]) -> dict: 1.240 + """\ 1.241 + Создать новый словарь на основе существующего, добавив в него только нужные ключи 1.242 + """ 1.243 + 1.244 + return dict((key, val) for key, val in data.items() if key in need_keys) 1.245 + 1.246 + 1.247 +def json_type_sanitizer(val): 1.248 + """\ 1.249 + Преобразует значение ``val`` в пригодное для преобразования в json значение. 1.250 + """ 1.251 + 1.252 + val_t = type(val) 1.253 + 1.254 + if is_dataclass(val): 1.255 + return json_type_sanitizer(asdict(val)) 1.256 + 1.257 + elif val_t in (int, float, str, bool) or val is None: 1.258 + return val 1.259 + 1.260 + elif val_t in (list, tuple): 1.261 + return list(map(json_type_sanitizer, val)) 1.262 + 1.263 + elif val_t == dict: 1.264 + return dict((key, json_type_sanitizer(d_val)) for key, d_val in val.items()) 1.265 + 1.266 + else: 1.267 + return str(val) 1.268 + 1.269 + 1.270 +def make_log_ident(user: Optional[str] = None) -> str: 1.271 + """\ 1.272 + Создаём строку, идентифицирующую данный запрос для журналирования операций. 1.273 + 1.274 + :param user: Если известен пользователь, задаём его здесь 1.275 + """ 1.276 + 1.277 + ip = get_client_ip() 1.278 + url_path = bottle.request.fullpath 1.279 + conn_id = bottle.request.get_header(CONN_ID_HEADER) 1.280 + 1.281 + if conn_id is not None: 1.282 + ip = f'{ip} | {conn_id}' 1.283 + 1.284 + if user is not None: 1.285 + ip = f'_NOID_[{ip}]' 1.286 + 1.287 + else: 1.288 + ip = f'{user}[{ip}]' 1.289 + 1.290 + return f'{ip} - {url_path}' 1.291 + 1.292 + 1.293 +def make_error_response( 1.294 + code: int, err: Union[Exception, str], 1.295 + msg: str = None, 1.296 + remove_cookies: Optional[List[str]] = None, 1.297 + cookies: Optional[List[Cookie]] = None 1.298 +) -> bottle.HTTPResponse: 1.299 + """\ 1.300 + Создание сообщения об ошибке для JSON REST API сервисов 1.301 + 1.302 + :param code: HTTP-код ответа 1.303 + :param err: Либо объект исключения, либо название ошибки 1.304 + :param msg: Если не ``None`` - сообщение об ошибке. В противном случае, если ``err`` является исключением, 1.305 + сообщение берётся из ``str(err)`` если нет, становится пустой строкой. 1.306 + :param remove_cookies: Список cookie, которые должны быть удалены с клиента. 1.307 + :param cookies: Набор cookies вставляемый в конструктор ответа без изменений. Читать в соответствующем 1.308 + параметре ``make_response`` 1.309 + """ 1.310 + 1.311 + if isinstance(err, Exception): 1.312 + err_type = type(err).__name__ 1.313 + err_msg = str(err) if msg is None else msg 1.314 + 1.315 + else: 1.316 + err_type = err 1.317 + err_msg = msg if msg is not None else '' 1.318 + 1.319 + res = make_response({ 1.320 + 'error': err_type, 1.321 + 'msg': err_msg, 1.322 + }, code, cookies) 1.323 + 1.324 + if remove_cookies is not None: 1.325 + for cookie_name in remove_cookies: 1.326 + res.delete_cookie(cookie_name) 1.327 + 1.328 + return res