ul-api-utils 9.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. example/__init__.py +0 -0
  2. example/conf.py +35 -0
  3. example/main.py +24 -0
  4. example/models/__init__.py +0 -0
  5. example/permissions.py +6 -0
  6. example/pure_flask_example.py +65 -0
  7. example/rate_limit_load.py +10 -0
  8. example/redis_repository.py +22 -0
  9. example/routes/__init__.py +0 -0
  10. example/routes/api_some.py +335 -0
  11. example/sockets/__init__.py +0 -0
  12. example/sockets/on_connect.py +16 -0
  13. example/sockets/on_disconnect.py +14 -0
  14. example/sockets/on_json.py +10 -0
  15. example/sockets/on_message.py +13 -0
  16. example/sockets/on_open.py +16 -0
  17. example/workers/__init__.py +0 -0
  18. example/workers/worker.py +28 -0
  19. ul_api_utils/__init__.py +0 -0
  20. ul_api_utils/access/__init__.py +122 -0
  21. ul_api_utils/api_resource/__init__.py +0 -0
  22. ul_api_utils/api_resource/api_request.py +105 -0
  23. ul_api_utils/api_resource/api_resource.py +414 -0
  24. ul_api_utils/api_resource/api_resource_config.py +20 -0
  25. ul_api_utils/api_resource/api_resource_error_handling.py +21 -0
  26. ul_api_utils/api_resource/api_resource_fn_typing.py +356 -0
  27. ul_api_utils/api_resource/api_resource_type.py +16 -0
  28. ul_api_utils/api_resource/api_response.py +300 -0
  29. ul_api_utils/api_resource/api_response_db.py +26 -0
  30. ul_api_utils/api_resource/api_response_payload_alias.py +25 -0
  31. ul_api_utils/api_resource/db_types.py +9 -0
  32. ul_api_utils/api_resource/signature_check.py +41 -0
  33. ul_api_utils/commands/__init__.py +0 -0
  34. ul_api_utils/commands/cmd_enc_keys.py +172 -0
  35. ul_api_utils/commands/cmd_gen_api_user_token.py +77 -0
  36. ul_api_utils/commands/cmd_gen_new_api_user.py +106 -0
  37. ul_api_utils/commands/cmd_generate_api_docs.py +181 -0
  38. ul_api_utils/commands/cmd_start.py +110 -0
  39. ul_api_utils/commands/cmd_worker_start.py +76 -0
  40. ul_api_utils/commands/start/__init__.py +0 -0
  41. ul_api_utils/commands/start/gunicorn.conf.local.py +0 -0
  42. ul_api_utils/commands/start/gunicorn.conf.py +26 -0
  43. ul_api_utils/commands/start/wsgi.py +22 -0
  44. ul_api_utils/conf/ul-debugger-main.js +1 -0
  45. ul_api_utils/conf/ul-debugger-ui.js +1 -0
  46. ul_api_utils/conf.py +70 -0
  47. ul_api_utils/const.py +78 -0
  48. ul_api_utils/debug/__init__.py +0 -0
  49. ul_api_utils/debug/debugger.py +119 -0
  50. ul_api_utils/debug/malloc.py +93 -0
  51. ul_api_utils/debug/stat.py +444 -0
  52. ul_api_utils/encrypt/__init__.py +0 -0
  53. ul_api_utils/encrypt/encrypt_decrypt_abstract.py +15 -0
  54. ul_api_utils/encrypt/encrypt_decrypt_aes_xtea.py +59 -0
  55. ul_api_utils/errors.py +200 -0
  56. ul_api_utils/internal_api/__init__.py +0 -0
  57. ul_api_utils/internal_api/__tests__/__init__.py +0 -0
  58. ul_api_utils/internal_api/__tests__/internal_api.py +29 -0
  59. ul_api_utils/internal_api/__tests__/internal_api_content_type.py +22 -0
  60. ul_api_utils/internal_api/internal_api.py +369 -0
  61. ul_api_utils/internal_api/internal_api_check_context.py +42 -0
  62. ul_api_utils/internal_api/internal_api_error.py +17 -0
  63. ul_api_utils/internal_api/internal_api_response.py +296 -0
  64. ul_api_utils/main.py +29 -0
  65. ul_api_utils/modules/__init__.py +0 -0
  66. ul_api_utils/modules/__tests__/__init__.py +0 -0
  67. ul_api_utils/modules/__tests__/test_api_sdk_jwt.py +195 -0
  68. ul_api_utils/modules/api_sdk.py +555 -0
  69. ul_api_utils/modules/api_sdk_config.py +63 -0
  70. ul_api_utils/modules/api_sdk_jwt.py +377 -0
  71. ul_api_utils/modules/intermediate_state.py +34 -0
  72. ul_api_utils/modules/worker_context.py +35 -0
  73. ul_api_utils/modules/worker_sdk.py +109 -0
  74. ul_api_utils/modules/worker_sdk_config.py +13 -0
  75. ul_api_utils/py.typed +0 -0
  76. ul_api_utils/resources/__init__.py +0 -0
  77. ul_api_utils/resources/caching.py +196 -0
  78. ul_api_utils/resources/debugger_scripts.py +97 -0
  79. ul_api_utils/resources/health_check/__init__.py +0 -0
  80. ul_api_utils/resources/health_check/const.py +2 -0
  81. ul_api_utils/resources/health_check/health_check.py +439 -0
  82. ul_api_utils/resources/health_check/health_check_template.py +64 -0
  83. ul_api_utils/resources/health_check/resource.py +97 -0
  84. ul_api_utils/resources/not_implemented.py +25 -0
  85. ul_api_utils/resources/permissions.py +29 -0
  86. ul_api_utils/resources/rate_limitter.py +84 -0
  87. ul_api_utils/resources/socketio.py +55 -0
  88. ul_api_utils/resources/swagger.py +119 -0
  89. ul_api_utils/resources/web_forms/__init__.py +0 -0
  90. ul_api_utils/resources/web_forms/custom_fields/__init__.py +0 -0
  91. ul_api_utils/resources/web_forms/custom_fields/custom_checkbox_select.py +5 -0
  92. ul_api_utils/resources/web_forms/custom_widgets/__init__.py +0 -0
  93. ul_api_utils/resources/web_forms/custom_widgets/custom_select_widget.py +86 -0
  94. ul_api_utils/resources/web_forms/custom_widgets/custom_text_input_widget.py +42 -0
  95. ul_api_utils/resources/web_forms/uni_form.py +75 -0
  96. ul_api_utils/sentry.py +52 -0
  97. ul_api_utils/utils/__init__.py +0 -0
  98. ul_api_utils/utils/__tests__/__init__.py +0 -0
  99. ul_api_utils/utils/__tests__/api_path_version.py +16 -0
  100. ul_api_utils/utils/__tests__/unwrap_typing.py +67 -0
  101. ul_api_utils/utils/api_encoding.py +51 -0
  102. ul_api_utils/utils/api_format.py +61 -0
  103. ul_api_utils/utils/api_method.py +55 -0
  104. ul_api_utils/utils/api_pagination.py +58 -0
  105. ul_api_utils/utils/api_path_version.py +60 -0
  106. ul_api_utils/utils/api_request_info.py +6 -0
  107. ul_api_utils/utils/avro.py +131 -0
  108. ul_api_utils/utils/broker_topics_message_count.py +47 -0
  109. ul_api_utils/utils/cached_per_request.py +23 -0
  110. ul_api_utils/utils/colors.py +31 -0
  111. ul_api_utils/utils/constants.py +7 -0
  112. ul_api_utils/utils/decode_base64.py +9 -0
  113. ul_api_utils/utils/deprecated.py +19 -0
  114. ul_api_utils/utils/flags.py +29 -0
  115. ul_api_utils/utils/flask_swagger_generator/__init__.py +0 -0
  116. ul_api_utils/utils/flask_swagger_generator/conf.py +4 -0
  117. ul_api_utils/utils/flask_swagger_generator/exceptions.py +7 -0
  118. ul_api_utils/utils/flask_swagger_generator/specifiers/__init__.py +0 -0
  119. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_models.py +57 -0
  120. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_specifier.py +48 -0
  121. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_three_specifier.py +777 -0
  122. ul_api_utils/utils/flask_swagger_generator/specifiers/swagger_version.py +40 -0
  123. ul_api_utils/utils/flask_swagger_generator/utils/__init__.py +0 -0
  124. ul_api_utils/utils/flask_swagger_generator/utils/input_type.py +77 -0
  125. ul_api_utils/utils/flask_swagger_generator/utils/parameter_type.py +51 -0
  126. ul_api_utils/utils/flask_swagger_generator/utils/replace_in_dict.py +18 -0
  127. ul_api_utils/utils/flask_swagger_generator/utils/request_type.py +52 -0
  128. ul_api_utils/utils/flask_swagger_generator/utils/schema_type.py +15 -0
  129. ul_api_utils/utils/flask_swagger_generator/utils/security_type.py +39 -0
  130. ul_api_utils/utils/imports.py +16 -0
  131. ul_api_utils/utils/instance_checks.py +16 -0
  132. ul_api_utils/utils/jinja/__init__.py +0 -0
  133. ul_api_utils/utils/jinja/t_url_for.py +19 -0
  134. ul_api_utils/utils/jinja/to_pretty_json.py +11 -0
  135. ul_api_utils/utils/json_encoder.py +126 -0
  136. ul_api_utils/utils/load_modules.py +15 -0
  137. ul_api_utils/utils/memory_db/__init__.py +0 -0
  138. ul_api_utils/utils/memory_db/__tests__/__init__.py +0 -0
  139. ul_api_utils/utils/memory_db/errors.py +8 -0
  140. ul_api_utils/utils/memory_db/repository.py +102 -0
  141. ul_api_utils/utils/token_check.py +14 -0
  142. ul_api_utils/utils/token_check_through_request.py +16 -0
  143. ul_api_utils/utils/unwrap_typing.py +117 -0
  144. ul_api_utils/utils/uuid_converter.py +22 -0
  145. ul_api_utils/validators/__init__.py +0 -0
  146. ul_api_utils/validators/__tests__/__init__.py +0 -0
  147. ul_api_utils/validators/__tests__/test_custom_fields.py +32 -0
  148. ul_api_utils/validators/custom_fields.py +66 -0
  149. ul_api_utils/validators/validate_empty_object.py +10 -0
  150. ul_api_utils/validators/validate_uuid.py +11 -0
  151. ul_api_utils-9.3.0.dist-info/LICENSE +21 -0
  152. ul_api_utils-9.3.0.dist-info/METADATA +279 -0
  153. ul_api_utils-9.3.0.dist-info/RECORD +156 -0
  154. ul_api_utils-9.3.0.dist-info/WHEEL +5 -0
  155. ul_api_utils-9.3.0.dist-info/entry_points.txt +2 -0
  156. ul_api_utils-9.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,122 @@
1
+ import re
2
+ from typing import Dict, List, Union, Any, Iterable
3
+
4
+ from pydantic import ConfigDict, BaseModel
5
+
6
+ from ul_api_utils.conf import APPLICATION_F
7
+
8
+
9
+ class PermissionDefinition(BaseModel):
10
+ id: int
11
+ key: str
12
+ name: str
13
+ category: str
14
+ flags: str = ''
15
+
16
+ model_config = ConfigDict(frozen=True)
17
+
18
+
19
+ class PermissionRegistry:
20
+ """
21
+ Examples:
22
+ reg = PermissionRegistry('some_service_name', 10000, 10100)
23
+ PII_GET_USER = reg.add(PermissionDefinition.new('PII_GET_USER', 1, 'permission get user', 'user'))
24
+ """
25
+
26
+ __slots__ = (
27
+ '_service_name',
28
+ '_index_by_id',
29
+ '_index_by_key',
30
+ '_index_by_category',
31
+ '_permission_ids',
32
+ '_categories',
33
+ '_start_id',
34
+ '_end_id',
35
+ )
36
+
37
+ def __init__(self, service_name: str, start_id: int, end_id: int) -> None:
38
+ self._service_name = service_name
39
+ self._index_by_id: Dict[int, PermissionDefinition] = dict()
40
+ self._index_by_key: Dict[str, PermissionDefinition] = dict()
41
+ self._index_by_category: Dict[str, List[PermissionDefinition]] = dict()
42
+ self._permission_ids: List[int] = list()
43
+ self._categories: List[str] = list()
44
+
45
+ self._start_id = start_id
46
+ self._end_id = end_id
47
+
48
+ @staticmethod
49
+ def get_ids_from_iterable(permissions: Iterable[PermissionDefinition]) -> List[int]:
50
+ return [p.id for p in permissions]
51
+
52
+ def has(self, permission: Union[int, PermissionDefinition]) -> bool:
53
+ return (permission.id if isinstance(permission, PermissionDefinition) else permission) in self._index_by_id
54
+
55
+ def add(self, key: str, id: int, name: str, category: str, flags: str = '') -> PermissionDefinition:
56
+ assert isinstance(id, int), f"id must be int. {type(id)} given"
57
+ assert isinstance(name, str), f"name must be str. {type(name)} given"
58
+ assert isinstance(key, str), f"key must be str. {type(key)} given"
59
+ assert isinstance(category, str), f"category must be str. {type(category)} given"
60
+
61
+ id = self._start_id + id # BECAUSE BASE COULD BE CHANGED
62
+
63
+ assert id not in {GLOBAL_PERMISSION__PRIVATE.id, GLOBAL_PERMISSION__PUBLIC.id, GLOBAL_PERMISSION__PRIVATE_RT.id}
64
+
65
+ if id in self._index_by_id:
66
+ raise ValueError(f'duplicate id={id}')
67
+ if key in self._index_by_key:
68
+ raise ValueError(f'duplicate key={key}')
69
+ if id > self._end_id:
70
+ raise ValueError(f'permission id={id} > {self._end_id}')
71
+ if id < self._start_id:
72
+ raise ValueError(f'permission id={id} < {self._end_id}')
73
+ if not re.match(r'^[A-Z][A-Z0-9_]+[A-Z0-9]$', key):
74
+ raise ValueError('key has invalid template')
75
+
76
+ permission = PermissionDefinition(
77
+ id=id,
78
+ key=key,
79
+ name=name,
80
+ category=category,
81
+ flags=flags,
82
+ )
83
+
84
+ self._permission_ids.append(id)
85
+
86
+ self._index_by_id[id] = permission
87
+ self._index_by_key[key] = permission
88
+
89
+ if category not in self._index_by_category:
90
+ self._index_by_category[category] = list()
91
+ self._categories.append(category)
92
+ self._index_by_category[category].append(permission)
93
+
94
+ return permission
95
+
96
+ def get_categories_with_permissions(self) -> List[Dict[str, Union[str, List[Dict[str, Union[int, str]]]]]]:
97
+ result: List[Dict[str, Any]] = []
98
+ for c in self._categories:
99
+ perms = []
100
+ for p in self._index_by_category[c]:
101
+ if not p.flags or APPLICATION_F.has_or_unset(p.flags):
102
+ perms.append(dict(id=p.id, name=p.name, key=p.key))
103
+ result.append(dict(
104
+ category=c,
105
+ service=self._service_name,
106
+ permissions=perms,
107
+ ))
108
+ return result
109
+
110
+ def get_permissions_ids(self) -> List[int]:
111
+ return self._permission_ids
112
+
113
+ def get_categories(self) -> List[str]:
114
+ return self._categories
115
+
116
+ def get_permissions_ids_of_category(self, category: str) -> List[int]:
117
+ return [p.id for p in self._index_by_category[category]]
118
+
119
+
120
+ GLOBAL_PERMISSION__PUBLIC = PermissionDefinition(id=0, key='PUBLIC', name='public access', category='')
121
+ GLOBAL_PERMISSION__PRIVATE = PermissionDefinition(id=1, key='PRIVATE_AT', name='user must be logged in', category='')
122
+ GLOBAL_PERMISSION__PRIVATE_RT = PermissionDefinition(id=2, key='PRIVATE_RT', name='user must be logged in and resource must check only refresh token', category='')
File without changes
@@ -0,0 +1,105 @@
1
+ import json
2
+ from typing import NamedTuple, Iterable, Any, List, Tuple, Dict, Optional
3
+
4
+ from flask_sqlalchemy.query import Query
5
+ from pydantic import model_validator, BaseModel
6
+
7
+ from ul_api_utils.errors import SimpleValidateApiError
8
+ from ul_api_utils.utils.api_pagination import ApiPagination
9
+ from ul_api_utils.utils.imports import has_already_imported_db
10
+
11
+
12
+ class ApiRequestQueryPagination(NamedTuple):
13
+ page: int
14
+ limit: int
15
+ offset: int
16
+ per_page: int
17
+
18
+ def mk_item_pagination(self, items: Iterable[Any], total: int) -> ApiPagination:
19
+ return ApiPagination(
20
+ total=total,
21
+ per_page=self.per_page,
22
+ page=self.page,
23
+ items=items,
24
+ )
25
+
26
+ # TODO: Not sure that is worked. Check it if will be found usages
27
+ def mk_sqlalchemy_pagination(self, items: Iterable[Any], total: int, query: Any = None) -> 'ApiPagination':
28
+ if has_already_imported_db() and query:
29
+ return query.paginate(total=total, query=query, per_page=self.per_page, page=self.page, items=items) # type: ignore
30
+ return self.mk_item_pagination(items, total)
31
+
32
+
33
+ class ApiRequestQuerySortBy(NamedTuple):
34
+ params: List[Tuple[str, str]]
35
+
36
+
37
+ class ApiRequestQueryFilterBy(NamedTuple):
38
+ params: List[Dict[str, Any]]
39
+
40
+
41
+ class ApiRequestQuery(BaseModel):
42
+ sort: Optional[str] = None
43
+ filter: Optional[str] = None
44
+ limit: Optional[int] = None
45
+ offset: Optional[int] = None
46
+ page: Optional[int] = None
47
+
48
+ @model_validator(mode="before")
49
+ @classmethod
50
+ def validate_empty_values(cls, values: Dict[str, Any]) -> Dict[str, Any]:
51
+ vals = dict()
52
+ for k, v in values.items():
53
+ vals[k] = v if v != "" else None
54
+ return vals
55
+
56
+ def pagination(self, default_limit: int, max_limit: int) -> ApiRequestQueryPagination:
57
+ try:
58
+ offset = max(int(self.offset or '0'), 0)
59
+ except Exception: # noqa: B902
60
+ offset = 0
61
+
62
+ try:
63
+ limit = min(max(int(self.limit or str(default_limit)), 0), max_limit)
64
+ except Exception: # noqa: B902
65
+ limit = default_limit
66
+
67
+ try:
68
+ page = max(int(self.page or '1'), 1)
69
+ except Exception: # noqa: B902
70
+ page = 1
71
+
72
+ if self.page is not None:
73
+ offset = limit * (page - 1)
74
+ else:
75
+ page = int(offset / limit) + 1
76
+
77
+ return ApiRequestQueryPagination(
78
+ limit=limit,
79
+ offset=offset,
80
+ page=page,
81
+ per_page=limit,
82
+ )
83
+
84
+ def filter_by(self, attr: str = "filter") -> List[Dict[str, Any]]:
85
+ filter_value = getattr(self, attr)
86
+ if not filter_value:
87
+ return []
88
+ _filter_by = json.loads(filter_value)
89
+ if not all([isinstance(filter_arg, dict) for filter_arg in _filter_by]):
90
+ raise SimpleValidateApiError('invalid filters format')
91
+ return _filter_by
92
+
93
+ def sort_by(self, attr: str = "sort") -> List[Tuple[str, str]]:
94
+ sort_value = getattr(self, attr)
95
+ _sort_by: List[Tuple[str, str]] = []
96
+ if not sort_value or not sort_value.strip():
97
+ return _sort_by
98
+ for _sort_arg in sort_value.strip().split(' '):
99
+ if not _sort_arg:
100
+ continue
101
+ if _sort_arg.startswith("+") or _sort_arg.startswith("-"):
102
+ _sort_by.append((_sort_arg[0], _sort_arg[1:]))
103
+ else:
104
+ _sort_by.append(("+", _sort_arg))
105
+ return _sort_by
@@ -0,0 +1,414 @@
1
+ import io
2
+ import json
3
+ import logging
4
+ import os
5
+ from datetime import datetime
6
+ from tempfile import NamedTemporaryFile
7
+ from typing import List, Dict, Any, Optional, Tuple, Union, BinaryIO, Mapping, TypeVar, Callable, Set, TYPE_CHECKING
8
+
9
+ from flask import render_template, request
10
+ from pydantic_core import PydanticCustomError
11
+ from werkzeug.datastructures import FileStorage
12
+
13
+ from ul_api_utils.access import GLOBAL_PERMISSION__PUBLIC, PermissionDefinition, GLOBAL_PERMISSION__PRIVATE_RT, GLOBAL_PERMISSION__PRIVATE
14
+ from ul_api_utils.api_resource.api_resource_config import ApiResourceConfig
15
+ from ul_api_utils.api_resource.api_resource_error_handling import WEB_EXCEPTION_HANDLING_PARAMS__MAP, \
16
+ ProcessingExceptionsParams, WEB_UNKNOWN_ERROR_PARAMS
17
+ from ul_api_utils.api_resource.api_resource_fn_typing import ApiResourceFnTyping
18
+ from ul_api_utils.api_resource.api_resource_type import ApiResourceType
19
+ from ul_api_utils.api_resource.api_response import JsonApiResponse, HtmlApiResponse, FileApiResponse, \
20
+ JsonApiResponsePayload, EmptyJsonApiResponse, \
21
+ RedirectApiResponse, ProxyJsonApiResponse, RootJsonApiResponse, RootJsonApiResponsePayload
22
+ from ul_api_utils.conf import APPLICATION_ENV, APPLICATION_JWT_PUBLIC_KEY, APPLICATION_DEBUG
23
+ from ul_api_utils.const import REQUEST_HEADER__X_FORWARDED_FOR, \
24
+ RESPONSE_HEADER__WWW_AUTH, OOPS, REQUEST_HEADER__USER_AGENT
25
+ from ul_api_utils.errors import ValidationListApiError, AccessApiError, NoResultFoundApiError, PermissionDeniedApiError, \
26
+ SimpleValidateApiError, ValidateApiError, HasAlreadyExistsApiError, AbstractInternalApiError, \
27
+ InvalidContentTypeError
28
+ from ul_api_utils.internal_api.internal_api_response import InternalApiResponse
29
+ from ul_api_utils.modules.api_sdk_config import ApiSdkConfig
30
+ from ul_api_utils.modules.api_sdk_jwt import ApiSdkJwt, JWT_VERSION
31
+ from ul_api_utils.utils.api_method import ApiMethod
32
+ from ul_api_utils.utils.api_request_info import ApiRequestInfo
33
+
34
+
35
+ if TYPE_CHECKING:
36
+ from ul_api_utils.api_resource.db_types import TPayloadInputUnion
37
+
38
+ TPayload = TypeVar('TPayload')
39
+
40
+
41
+ T = TypeVar('T')
42
+ TResp = TypeVar('TResp', bound=Union[JsonApiResponsePayload, RootJsonApiResponsePayload[Any]])
43
+
44
+
45
+ class ApiResource:
46
+ __slots__ = (
47
+ '_debugger_enabled',
48
+ '_token',
49
+ '_token_raw',
50
+ '_type',
51
+ '_config',
52
+ '_api_resource_config',
53
+ '_fn_typing',
54
+ '_logger',
55
+ '_headers',
56
+ '_access',
57
+ '_method',
58
+ '_internal_use__files_to_clean',
59
+ '_now',
60
+ '_limiter_enabled',
61
+ '_db_initialized',
62
+ )
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ logger: logging.Logger,
68
+ debugger_enabled: bool,
69
+ type: ApiResourceType,
70
+ config: ApiSdkConfig,
71
+ access: PermissionDefinition,
72
+ headers: Mapping[str, str],
73
+ api_resource_config: Optional[ApiResourceConfig] = None,
74
+ fn_typing: ApiResourceFnTyping,
75
+ limiter_enabled: bool,
76
+ db_initialized: bool,
77
+ ) -> None:
78
+ self._debugger_enabled = debugger_enabled
79
+ self._token: Optional[ApiSdkJwt] = None
80
+ self._token_raw: Optional[str] = None
81
+ self._type = type
82
+ self._config = config
83
+ self._api_resource_config = api_resource_config or ApiResourceConfig()
84
+ self._fn_typing = fn_typing
85
+ self._logger = logger
86
+
87
+ self._headers = headers
88
+ self._access = access
89
+ self._method = ApiMethod(str(request.method).strip().upper()) # todo: move it in host function
90
+ self._internal_use__files_to_clean: Set[str] = set()
91
+ self._now = datetime.now()
92
+ self._limiter_enabled = limiter_enabled
93
+ self._db_initialized = db_initialized
94
+
95
+ def __repr__(self) -> str:
96
+ return f"ApiResource object. Type: {self._type}, Function: {self._fn_typing.fn.__name__}"
97
+
98
+ @property
99
+ def debugger_enabled(self) -> bool:
100
+ return self._debugger_enabled
101
+
102
+ def mk_tmp_file(
103
+ self,
104
+ suffix: str | None = None,
105
+ prefix: str | None = None,
106
+ ) -> str:
107
+ f = NamedTemporaryFile(suffix=suffix, prefix=prefix)
108
+ f.seek(0)
109
+ name = f.name
110
+ self._internal_use__files_to_clean.add(str(name))
111
+ return name
112
+
113
+ @property
114
+ def logger(self) -> logging.Logger:
115
+ return self._logger
116
+
117
+ @property
118
+ def default_response_payload(self) -> Optional[List[Any]]:
119
+ ApiResourceType.API.validate(self._type)
120
+ return None if not self._fn_typing.response_payload_many else []
121
+
122
+ @property
123
+ def method(self) -> ApiMethod:
124
+ return self._method
125
+
126
+ @property
127
+ def request_files(self) -> Mapping[str, FileStorage]:
128
+ return request.files
129
+
130
+ @property
131
+ def request_headers(self) -> Mapping[str, str]:
132
+ return self._headers
133
+
134
+ @property
135
+ def request_info(self) -> ApiRequestInfo:
136
+ x_forwarded_for = self._headers.get(REQUEST_HEADER__X_FORWARDED_FOR)
137
+ return ApiRequestInfo(
138
+ user_agent=request.headers.get(REQUEST_HEADER__USER_AGENT, ''),
139
+ ipv4=x_forwarded_for.split(",")[0] if x_forwarded_for else request.remote_addr or '',
140
+ )
141
+
142
+ @property
143
+ def auth_token_raw(self) -> str:
144
+ if self._access == GLOBAL_PERMISSION__PUBLIC:
145
+ raise OverflowError('you could not use token in public api method')
146
+ assert self._token_raw is not None
147
+ return self._token_raw
148
+
149
+ @property
150
+ def auth_token(self) -> ApiSdkJwt:
151
+ if self._access == GLOBAL_PERMISSION__PUBLIC:
152
+ raise OverflowError('you could not use token in public api method')
153
+ assert self._token is not None
154
+ return self._token
155
+
156
+ def _mk_error(self, error_type: str, error_message: str, debug_specific: bool = True) -> Dict[str, str]:
157
+ error_message = error_message if not debug_specific or (self._debugger_enabled or APPLICATION_DEBUG) else OOPS
158
+ return {
159
+ "error_type": error_type,
160
+ "error_message": error_message,
161
+ }
162
+
163
+ def response_api_error(self, err: Exception) -> JsonApiResponse[JsonApiResponsePayload]:
164
+ if isinstance(err, ValidationListApiError):
165
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, err.errors)
166
+ if isinstance(err, json.decoder.JSONDecodeError):
167
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [self._mk_error("validation-error", 'json decode failed', False)])
168
+ if isinstance(err, PermissionDeniedApiError):
169
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 403, [self._mk_error("permission-error", 'no rights', False)])
170
+ if isinstance(err, AccessApiError):
171
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 401, [self._mk_error("access-error", str(err), False)])
172
+ if isinstance(err, SimpleValidateApiError):
173
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [self._mk_error("validation-error", str(err), False)])
174
+ if isinstance(err, (PydanticCustomError, ValidateApiError)):
175
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [{
176
+ "error_kind": f"value_error.{err.code}", # type: ignore
177
+ "error_location": err.location, # type: ignore
178
+ "error_type": "body-validation-error",
179
+ "error_message": f"{err.msg_template}", # type: ignore
180
+ "error_input": err.input, # type: ignore
181
+ }])
182
+ if isinstance(err, NoResultFoundApiError):
183
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 404, [
184
+ self._mk_error("validation-error", str(err), False),
185
+ ])
186
+ if isinstance(err, HasAlreadyExistsApiError):
187
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [
188
+ self._mk_error("validation-error", str(err) or "Resource already exist", False),
189
+ ])
190
+
191
+ if isinstance(err, InvalidContentTypeError):
192
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [
193
+ self._mk_error("validation-error", str(err), False),
194
+ ])
195
+
196
+ if self._db_initialized:
197
+ from ul_db_utils.errors.db_filter_error import DBFiltersError
198
+ from ul_db_utils.errors.db_sort_error import DBSortError
199
+ from sqlalchemy.exc import NoResultFound as NoResultFoundError
200
+ if isinstance(err, (DBFiltersError, DBSortError)):
201
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 400, [
202
+ self._mk_error("query-validation-error", str(err), False),
203
+ ])
204
+ if isinstance(err, NoResultFoundError):
205
+ e = self._mk_error("validation-error", err._message() if err.args else "Resource not found", False) # type: ignore
206
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 404, [e])
207
+
208
+ if self._limiter_enabled:
209
+ from flask_limiter import RateLimitExceeded
210
+ if isinstance(err, RateLimitExceeded):
211
+ return JsonApiResponse._internal_use_response_error(
212
+ self._fn_typing.response_payload_many,
213
+ 429,
214
+ [self._mk_error("rate-limit-exceeded", f"request outside of rate limit - {err.limit.limit}", False)],
215
+ )
216
+
217
+ return JsonApiResponse._internal_use_response_error(self._fn_typing.response_payload_many, 500, [self._mk_error("system-error", str(err))])
218
+
219
+ def _generate_web_error_response(
220
+ self,
221
+ happened_exception: Exception,
222
+ default_status_code: int,
223
+ error_message: str,
224
+ apply_auth_headers: bool = False,
225
+ ) -> HtmlApiResponse:
226
+ if hasattr(happened_exception, "status_code"):
227
+ default_status_code = happened_exception.status_code
228
+ assert self._config.web_error_template is not None # only for mypy
229
+ response = HtmlApiResponse(
230
+ content=self.render_template(
231
+ self._config.web_error_template,
232
+ {
233
+ "error_traceback": happened_exception,
234
+ "error_message": error_message,
235
+ "status_code": default_status_code,
236
+ },
237
+ ),
238
+ ok=False,
239
+ status_code=default_status_code,
240
+ )
241
+ if apply_auth_headers:
242
+ if self.method != ApiMethod.OPTIONS and self._config.http_auth is not None:
243
+ response.headers[RESPONSE_HEADER__WWW_AUTH] = f'{self._config.http_auth.scheme} realm="{self._config.http_auth.realm}"'
244
+ return response
245
+ return response
246
+
247
+ def response_web_error(self, err: Exception) -> HtmlApiResponse:
248
+ if self._config.web_error_template is None:
249
+ return HtmlApiResponse(error=err, content=OOPS, ok=False, status_code=500)
250
+
251
+ if self._db_initialized:
252
+ from sqlalchemy.exc import NoResultFound as NoResultFoundError
253
+
254
+ WEB_EXCEPTION_HANDLING_PARAMS__MAP[NoResultFoundError] = ProcessingExceptionsParams(default_status_code=404, error_message="Not found")
255
+
256
+ error_response_params = WEB_EXCEPTION_HANDLING_PARAMS__MAP.get(type(err), WEB_UNKNOWN_ERROR_PARAMS)
257
+
258
+ return self._generate_web_error_response(
259
+ happened_exception=err,
260
+ default_status_code=error_response_params.default_status_code,
261
+ error_message=error_response_params.error_message,
262
+ apply_auth_headers=error_response_params.apply_auth_headers,
263
+ )
264
+
265
+ def render_template(self, template_name: str, data: Dict[str, Any]) -> str:
266
+ return render_template(
267
+ template_name_or_list=template_name,
268
+ **data,
269
+ NOW=self._now,
270
+ )
271
+
272
+ def response_template(self, template_name: str, **kwargs: Any) -> HtmlApiResponse:
273
+ ApiResourceType.WEB.validate(self._type)
274
+ return HtmlApiResponse(
275
+ ok=True,
276
+ status_code=200,
277
+ content=self.render_template(template_name, kwargs),
278
+ )
279
+
280
+ def response_proxy(self, response: InternalApiResponse[TResp]) -> ProxyJsonApiResponse[TResp]:
281
+ try:
282
+ response.check()
283
+ except AbstractInternalApiError:
284
+ pass
285
+ return ProxyJsonApiResponse(ok=response.ok, status_code=response.status_code, response=response.result_json)
286
+
287
+ def response_redirect(self, location: str) -> RedirectApiResponse:
288
+ return RedirectApiResponse(ok=True, location=location)
289
+
290
+ def response_file_ok(
291
+ self,
292
+ path_or_file: Union[str, BinaryIO, io.BytesIO],
293
+
294
+ mimetype: Optional[str], # it will be auto-detected by extension if mimetype==None
295
+ as_attachment: bool = False,
296
+ download_name: Optional[str] = None,
297
+ conditional: bool = True,
298
+ etag: Union[bool, str] = True,
299
+ last_modified: Optional[Union[datetime, int, float]] = None,
300
+ max_age: Optional[Union[int, Callable[[Optional[str]], Optional[int]]]] = None,
301
+ ) -> FileApiResponse:
302
+ ApiResourceType.FILE.validate(self._type)
303
+ if isinstance(path_or_file, str) and not os.path.exists(path_or_file):
304
+ raise NoResultFoundApiError(f'file "{path_or_file}" is not exists')
305
+ return FileApiResponse(
306
+ ok=True,
307
+ status_code=200,
308
+ file_path=path_or_file,
309
+ mimetype=mimetype,
310
+ as_attachment=as_attachment,
311
+ download_name=download_name,
312
+ conditional=conditional,
313
+ etag=etag,
314
+ last_modified=last_modified,
315
+ max_age=max_age,
316
+ )
317
+
318
+ def response_created_ok(self, payload: 'TPayloadInputUnion', total_count: Optional[int] = None) -> JsonApiResponse[Any]:
319
+ ApiResourceType.API.validate(self._type)
320
+ return JsonApiResponse(
321
+ ok=True,
322
+ total_count=total_count,
323
+ payload=payload,
324
+ status_code=201,
325
+ )
326
+
327
+ def response_empty_ok(self) -> EmptyJsonApiResponse:
328
+ return EmptyJsonApiResponse(ok=True, status_code=200)
329
+
330
+ def response_root(self, payload: 'TPayloadInputUnion', status_code: int = 200) -> RootJsonApiResponse[Any]:
331
+ ApiResourceType.API.validate(self._type)
332
+ return RootJsonApiResponse(ok=True, status_code=status_code, root=payload)
333
+
334
+ def response_ok(self, payload: 'TPayloadInputUnion', total_count: Optional[int] = None) -> JsonApiResponse[Any]:
335
+ return JsonApiResponse(
336
+ ok=True,
337
+ payload=payload,
338
+ total_count=total_count,
339
+ status_code=200,
340
+ )
341
+
342
+ def response_deleted_ok(self) -> JsonApiResponse[Any]:
343
+ ApiResourceType.API.validate(self._type)
344
+
345
+ return JsonApiResponse(
346
+ ok=True,
347
+ payload=self.default_response_payload,
348
+ total_count=0 if self._fn_typing.response_payload_many else None,
349
+ status_code=200,
350
+ )
351
+
352
+ def _internal_use__check_access(self, jwt_token: Optional[Tuple[Optional[str], str]]) -> None:
353
+ assert self._token is None
354
+
355
+ if self._access == GLOBAL_PERMISSION__PUBLIC:
356
+ return None
357
+
358
+ if jwt_token is None:
359
+ raise AccessApiError('empty token')
360
+
361
+ auth_username, auth_token = jwt_token
362
+
363
+ try:
364
+ t = ApiSdkJwt.decode(
365
+ token=auth_token,
366
+ username=auth_username,
367
+ certificate=APPLICATION_JWT_PUBLIC_KEY,
368
+ )
369
+ except Exception: # noqa: B902
370
+ raise AccessApiError('invalid token data')
371
+
372
+ if t.version != JWT_VERSION:
373
+ raise AccessApiError('token has invalid version')
374
+
375
+ if self._config.jwt_environment_check_enabled and t.env != APPLICATION_ENV:
376
+ raise AccessApiError('token has invalid environment')
377
+
378
+ if t.is_expired:
379
+ raise AccessApiError('token is expired')
380
+
381
+ if not self._config.permissions_check_enabled:
382
+ self._token = t
383
+ self._token_raw = auth_token
384
+ return None
385
+
386
+ if self._config.jwt_validator is not None:
387
+ try:
388
+ jwt_validation_result = self._config.jwt_validator(t)
389
+ if jwt_validation_result is not None and not jwt_validation_result:
390
+ raise AccessApiError('Permission denied')
391
+ except Exception as e: # noqa: B902
392
+ raise AccessApiError(str(e))
393
+
394
+ if self._access == GLOBAL_PERMISSION__PRIVATE_RT:
395
+ if not t.is_refresh_token:
396
+ raise AccessApiError('invalid token type')
397
+ self._token_raw = auth_token
398
+ self._token = t
399
+ return None
400
+
401
+ if not t.is_access_token:
402
+ raise AccessApiError('invalid token type')
403
+
404
+ if self._access != GLOBAL_PERMISSION__PRIVATE:
405
+ if self._config.permissions_validator is not None:
406
+ perm_valid_result = self._config.permissions_validator(t, self._access)
407
+ if perm_valid_result is not None and not perm_valid_result:
408
+ raise PermissionDeniedApiError('no rights to make this action')
409
+ else:
410
+ if not t.has_permission(self._access):
411
+ raise PermissionDeniedApiError('no rights to make this action')
412
+
413
+ self._token_raw = auth_token
414
+ self._token = t
@@ -0,0 +1,20 @@
1
+ from typing import Optional, Callable, Tuple
2
+
3
+ from pydantic import ConfigDict, BaseModel
4
+
5
+ from ul_api_utils.api_resource.api_response import ApiResponse
6
+ from werkzeug import Response as BaseResponse
7
+
8
+
9
+ class ApiResourceConfig(BaseModel):
10
+ swagger_group: Optional[str] = None
11
+ swagger_disabled: bool = False
12
+ exc_handler_bad_request: Optional[Callable[[Exception], Optional[ApiResponse]]] = None
13
+ exc_handler_access: Optional[Callable[[Exception], Optional[ApiResponse]]] = None
14
+ exc_handler_endpoint: Optional[Callable[[Exception], Optional[ApiResponse]]] = None
15
+ override_flask_response: Optional[Callable[[Tuple[BaseResponse, int]], Tuple[BaseResponse, int]]] = None
16
+
17
+ model_config = ConfigDict(
18
+ extra="forbid",
19
+ frozen=True,
20
+ )
@@ -0,0 +1,21 @@
1
+ from typing import NamedTuple, Dict, Type
2
+
3
+ from ul_api_utils.errors import PermissionDeniedApiError, AccessApiError, ValidateApiError, \
4
+ Server5XXInternalApiError, Client4XXInternalApiError
5
+
6
+
7
+ class ProcessingExceptionsParams(NamedTuple):
8
+ default_status_code: int
9
+ error_message: str
10
+ apply_auth_headers: bool = False
11
+
12
+
13
+ WEB_EXCEPTION_HANDLING_PARAMS__MAP: Dict[Type[Exception], ProcessingExceptionsParams] = {
14
+ PermissionDeniedApiError: ProcessingExceptionsParams(default_status_code=403, error_message="Access not permitted", apply_auth_headers=True),
15
+ AccessApiError: ProcessingExceptionsParams(default_status_code=401, error_message="Invalid token", apply_auth_headers=True),
16
+ ValidateApiError: ProcessingExceptionsParams(default_status_code=400, error_message="Request validation error"),
17
+ Server5XXInternalApiError: ProcessingExceptionsParams(default_status_code=500, error_message="Server error"),
18
+ Client4XXInternalApiError: ProcessingExceptionsParams(default_status_code=400, error_message="Request validation error"),
19
+ }
20
+
21
+ WEB_UNKNOWN_ERROR_PARAMS = ProcessingExceptionsParams(default_status_code=500, error_message="Unknown error")