py.lib

Yohn Y. 2022-08-05 Child:483727ff89c4

31:4186c3b229fa Go to Latest

py.lib/webapp/bottle_urils.py

+ Модуль работы с датаклассами и их наполнения из ORM + Утилиты Bottle + Утилиты JWT + Помошник в парсинге конфигурационных файлов

History
     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