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,555 @@
1
+ import functools
2
+ import json
3
+ import logging
4
+ import os
5
+ import time
6
+ import traceback
7
+ from base64 import b64decode
8
+ from datetime import datetime
9
+ from functools import wraps
10
+ from typing import List, Union, Callable, Tuple, Any, Optional, TypeVar, cast, Set, TYPE_CHECKING
11
+ from flask import Response, request, Flask, url_for as flask_url_for, g
12
+ from pydantic import BaseModel
13
+ from redis.connection import parse_url
14
+ from ul_py_tool.utils.arg_files_glob import arg_files_print
15
+ from werkzeug import Response as BaseResponse
16
+ from ul_api_utils.access import PermissionDefinition, GLOBAL_PERMISSION__PRIVATE, GLOBAL_PERMISSION__PRIVATE_RT, \
17
+ GLOBAL_PERMISSION__PUBLIC, PermissionRegistry
18
+ from ul_api_utils.api_resource.api_resource import ApiResource
19
+ from ul_api_utils.api_resource.api_resource_config import ApiResourceConfig
20
+ from ul_api_utils.api_resource.api_resource_fn_typing import ApiResourceFnTyping
21
+ from ul_api_utils.api_resource.api_resource_type import ApiResourceType
22
+ from ul_api_utils.api_resource.api_response import ApiResponse, JsonApiResponse, RootJsonApiResponse, ProxyJsonApiResponse, AnyJsonApiResponse
23
+ from ul_api_utils.conf import APPLICATION_DEBUGGER_PIN, APPLICATION_DEBUG, APPLICATION_DIR, APPLICATION_ENV_IS_LOCAL, APPLICATION_START_DT, APPLICATION_DEBUG_LOGGING
24
+ from ul_api_utils.const import REQUEST_HEADER__DEBUGGER, REQUEST_HEADER__INTERNAL
25
+ from ul_api_utils.debug import stat
26
+ from ul_api_utils.debug.debugger import Debugger
27
+ from ul_api_utils.errors import UserAbstractApiError, ResourceRuntimeApiError, ResponseTypeRuntimeApiError
28
+ from ul_api_utils.internal_api.internal_api_check_context import internal_api_check_context
29
+ from ul_api_utils.modules.api_sdk_config import ApiSdkConfig
30
+ from ul_api_utils.modules.intermediate_state import try_init, try_configure
31
+ from ul_api_utils.resources.caching import ULCache, TCacheMode, ULCacheMode, ULCacheConfig
32
+ from ul_api_utils.resources.debugger_scripts import load_debugger_static_scripts
33
+ from ul_api_utils.resources.health_check.health_check import HealthCheckContext
34
+ from ul_api_utils.resources.health_check.resource import init_health_check_resource
35
+ from ul_api_utils.resources.not_implemented import not_implemented_handler
36
+ from ul_api_utils.resources.permissions import load_permissions
37
+ from ul_api_utils.resources.rate_limitter import init_rate_limiter
38
+ from ul_api_utils.resources.socketio import init_socket_io, SocketAsyncModesEnum, SocketIOConfigType
39
+ from ul_api_utils.resources.swagger import load_swagger, ApiSdkResource
40
+ from ul_api_utils.sentry import sentry
41
+ from ul_api_utils.utils.api_encoding import ApiEncoding
42
+ from ul_api_utils.utils.api_method import TMethod, ApiMethod, TMethodShort
43
+ from ul_api_utils.utils.api_path_version import ApiPathVersion
44
+ from ul_api_utils.utils.cached_per_request import cached_per_request
45
+ from ul_api_utils.utils.constants import TKwargs
46
+ from ul_api_utils.utils.jinja.t_url_for import t_url_for
47
+ from ul_api_utils.utils.jinja.to_pretty_json import to_pretty_json
48
+ from ul_api_utils.utils.json_encoder import CustomJSONProvider, SocketIOJsonWrapper
49
+ from ul_api_utils.utils.load_modules import load_modules_by_template
50
+ from ul_api_utils.utils.uuid_converter import UUID4Converter
51
+
52
+ if TYPE_CHECKING:
53
+ import flask_socketio # type: ignore # lib without mypy stubs
54
+ import flask_sqlalchemy
55
+ from flask_mongoengine import MongoEngine # type: ignore # lib without mypy stubs
56
+ from ul_db_utils.modules.postgres_modules.db import DbConfig
57
+ from ul_db_utils.modules.mongo_db_modules.db import MongoDbConfig
58
+
59
+ TFn = TypeVar("TFn", bound=Callable[..., ApiResponse])
60
+
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+
65
+ def add_files_to_clean(files: Set[str]) -> None:
66
+ if hasattr(g, '_api_utils_files_to_clean'):
67
+ for f in files:
68
+ g._api_utils_files_to_clean.add(f) # type: ignore
69
+ else:
70
+ g._api_utils_files_to_clean = files # type: ignore
71
+
72
+
73
+ def clean_files() -> None:
74
+ files: Set[str] = getattr(g, '_api_utils_files_to_clean', set())
75
+ for f in files:
76
+ try:
77
+ os.unlink(f)
78
+ except Exception as e: # noqa: B902
79
+ logger.warning(f'file {f} deleted before clean :: {e}')
80
+ files.clear()
81
+
82
+
83
+ def _get_error_types(response: ApiResponse) -> Optional[str]:
84
+ if isinstance(response, AnyJsonApiResponse) and isinstance(response.errors, (list, tuple)) and len(response.errors) > 0:
85
+ for err in response.errors:
86
+ err_t = None
87
+ if isinstance(err, BaseModel): # type: ignore
88
+ err_t = getattr(err, 'error_type', None) # type: ignore
89
+ elif isinstance(err, dict):
90
+ err_t = err.get('error_type', None)
91
+ if isinstance(err_t, str):
92
+ return err_t
93
+ return None
94
+
95
+
96
+ class ApiSdk:
97
+ ACCESS_PUBLIC = GLOBAL_PERMISSION__PUBLIC
98
+ ACCESS_PRIVATE = GLOBAL_PERMISSION__PRIVATE
99
+ ACCESS_PRIVATE_RT = GLOBAL_PERMISSION__PRIVATE_RT
100
+
101
+ __slots__ = (
102
+ '_config',
103
+ '_routes_loaded',
104
+ '_request_started_at',
105
+ '_initialized_flask_name',
106
+ '_templates_dir',
107
+ '_fn_registry',
108
+ '_flask_app_cache',
109
+ '_sio',
110
+ '_limiter_enabled',
111
+ '_cache',
112
+ '_db',
113
+ )
114
+
115
+ def __init__(self, config: ApiSdkConfig) -> None:
116
+ try_configure(self)
117
+
118
+ self._config = config
119
+ self._routes_loaded = False
120
+ self._request_started_at = time.perf_counter()
121
+ self._initialized_flask_name: Optional[str] = None
122
+ self._flask_app_cache: Optional[Flask] = None
123
+ self._limiter_enabled = False
124
+ self._cache = None
125
+ self._sio: Optional['flask_socketio.SocketIO'] = None
126
+ self._templates_dir = os.path.join(APPLICATION_DIR, 'templates')
127
+
128
+ self._fn_registry: List[ApiSdkResource] = []
129
+ self._db: Optional['flask_sqlalchemy.SQLAlchemy'] | Optional['MongoEngine'] = None
130
+
131
+ @property
132
+ def config(self) -> ApiSdkConfig:
133
+ return self._config
134
+
135
+ @property
136
+ def socket(self) -> 'flask_socketio.SocketIO':
137
+ assert self._sio is not None, "SocketIO is not configured, try adding SocketIOConfig to your ApiSdk first."
138
+ return self._sio
139
+
140
+ def init_with_flask(self, app_name: str, *, db_config: Optional['MongoDbConfig'] | Optional['DbConfig'] = None) -> Flask:
141
+ self._initialized_flask_name = try_init(self, app_name)
142
+ self._sio = init_socket_io(config=self._config.socket_config)
143
+
144
+ if db_config is not None and type(db_config).__name__ == 'MongoDbConfig':
145
+ from ul_db_utils.utils.waiting_for_mongo import waiting_for_mongo
146
+ from ul_db_utils.modules.mongo_db_modules.db import db
147
+ db_config._init_from_sdk_with_flask(self)
148
+ waiting_for_mongo(db_config.uri)
149
+ self._db = db
150
+
151
+ if db_config is not None and type(db_config).__name__ == 'DbConfig':
152
+ from ul_db_utils.utils.waiting_for_postgres import waiting_for_postgres
153
+ from ul_db_utils.modules.postgres_modules.db import db # yes, db already defined, but can not use two conigs
154
+ db_config._init_from_sdk_with_flask(self)
155
+ waiting_for_postgres(db_config.uri)
156
+ self._db = db
157
+
158
+ self._limiter_enabled = init_rate_limiter(
159
+ flask_app=self._flask_app,
160
+ debugger_enabled=self._debugger_enabled_with_pin,
161
+ get_auth_token=self._get_auth_token,
162
+ identify=self._config.rate_limit_identify,
163
+ rate_limit=self._config.rate_limit,
164
+ storage_uri=self._config.rate_limit_storage_uri,
165
+ )
166
+ if self._config.cache_storage_uri:
167
+ cache_config: ULCacheConfig = {
168
+ 'CACHE_TYPE': "RedisCache",
169
+ 'CACHE_REDIS_HOST': '',
170
+ 'CACHE_REDIS_PORT': '',
171
+ 'CACHE_REDIS_PASSWORD': '',
172
+ 'CACHE_DEFAULT_TIMEOUT': self._config.cache_default_ttl,
173
+ 'CACHE_KEY_PREFIX': f'CACHE__{self._config.service_name}',
174
+ 'CACHE_SOURCE_CHECK': True,
175
+ }
176
+ try:
177
+ redis_url = parse_url(self._config.cache_storage_uri)
178
+ except ValueError as e:
179
+ logger.error(f'broken redis uri :: {e}')
180
+ else:
181
+ assert all(('host' in redis_url, 'port' in redis_url)), 'missing part of redis uri'
182
+ cache_config['CACHE_REDIS_HOST'] = redis_url['host']
183
+ cache_config['CACHE_REDIS_PORT'] = redis_url['port']
184
+ cache_config['CACHE_REDIS_PASSWORD'] = redis_url.get('password', '')
185
+ self._cache = ULCache(self._flask_app, config=cache_config) # type: ignore
186
+
187
+ route_files, ignored_route_files = load_modules_by_template([
188
+ os.path.join(APPLICATION_DIR, 'routes', 'api_*.py'),
189
+ os.path.join(APPLICATION_DIR, 'routes', '**', 'api_*.py'),
190
+ os.path.join(APPLICATION_DIR, 'views', 'view_*.py'),
191
+ os.path.join(APPLICATION_DIR, 'views', '**', 'view_*.py'),
192
+ ])
193
+
194
+ if APPLICATION_DEBUG:
195
+ arg_files_print(1000, route_files, ignored_files=ignored_route_files, name='files loaded')
196
+
197
+ if (plugins_config := self.config.flask_debugging_plugins) is not None:
198
+ if plugins_config.flask_monitoring_dashboard:
199
+ import flask_monitoringdashboard as dashboard # type: ignore
200
+ if os.environ.get('FLASK_MONITORING_DASHBOARD_CONFIG'):
201
+ dashboard.config.init_from(envvar='FLASK_MONITORING_DASHBOARD_CONFIG', log_verbose=True)
202
+ dashboard.bind(self._flask_app)
203
+
204
+ load_permissions(self, self._initialized_flask_name, self._config.permissions)
205
+
206
+ load_debugger_static_scripts(self)
207
+
208
+ load_swagger(self, self._fn_registry, self._config.api_route_path_prefix)
209
+
210
+ return self._flask_app
211
+
212
+ @property
213
+ def _flask_app(self) -> Flask:
214
+ if self._flask_app_cache is not None:
215
+ return self._flask_app_cache
216
+
217
+ if not self._initialized_flask_name:
218
+ raise OverflowError('app was not initialized')
219
+
220
+ flask_app = Flask(
221
+ import_name=self._initialized_flask_name,
222
+ static_url_path=self._config.static_url_path,
223
+ static_folder=os.path.join(APPLICATION_DIR, 'static'),
224
+ template_folder=self._templates_dir,
225
+ )
226
+ self._flask_app_cache = flask_app
227
+
228
+ flask_app.json = CustomJSONProvider(flask_app) # type: ignore
229
+
230
+ flask_app.url_map.converters['uuid'] = UUID4Converter
231
+
232
+ flask_app.config['DEBUG'] = APPLICATION_ENV_IS_LOCAL and APPLICATION_DEBUG
233
+ flask_app.config['EXPLAIN_TEMPLATE_LOADING'] = False
234
+ flask_app.config['ENV'] = 'development' if APPLICATION_DEBUG else 'production'
235
+ flask_app.config['SECRET_KEY'] = 'some-long-long-secret-only-for-wtforms-string-be-brave-if-you-use-it-on-prod'
236
+ flask_app.config['TEMPLATES_AUTO_RELOAD'] = APPLICATION_DEBUG and APPLICATION_ENV_IS_LOCAL
237
+ flask_app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
238
+ flask_app.config['JSON_SORT_KEYS'] = False
239
+ flask_app.config['PREFERRED_URL_SCHEME'] = 'http'
240
+ flask_app.config['APPLICATION_ROOT'] = '/'
241
+
242
+ flask_app.add_template_filter(to_pretty_json, 'tojson_pretty')
243
+ flask_app.add_template_filter(to_pretty_json, 'to_json_pretty')
244
+
245
+ flask_app.add_template_global(t_url_for, 't_url_for')
246
+
247
+ flask_app.before_request(self._before_request)
248
+ flask_app.after_request(self._after_request)
249
+ flask_app.teardown_request(self._teardown_request)
250
+
251
+ flask_app.errorhandler(404)(functools.partial(
252
+ not_implemented_handler,
253
+ import_name=self._initialized_flask_name,
254
+ debugger_enabled=self._debugger_enabled_with_pin,
255
+ ))
256
+
257
+ if self._config.socket_config and self._config.socket_config.app_type is SocketIOConfigType.SERVER:
258
+ assert self._sio # mypy
259
+ self._sio.init_app(
260
+ flask_app,
261
+ json=SocketIOJsonWrapper,
262
+ message_queue=self._config.socket_config.message_queue,
263
+ async_mode=SocketAsyncModesEnum.GEVENT.value,
264
+ channel=self._config.socket_config.channel,
265
+ cors_allowed_origins=self._config.socket_config.cors_allowed_origins,
266
+ logger=self._config.socket_config.logs_enabled,
267
+ engineio_logger=self._config.socket_config.engineio_logs_enabled,
268
+ )
269
+ load_modules_by_template([os.path.join(APPLICATION_DIR, 'sockets', '*')])
270
+
271
+ return flask_app
272
+
273
+ @property
274
+ def ul_cache_factory(self) -> Optional['ULCache']:
275
+ return self._cache
276
+
277
+ @property
278
+ def db(self) -> Optional['flask_sqlalchemy.SQLAlchemy'] | Optional['MongoEngine']:
279
+ return self._db
280
+
281
+ def _before_request(self) -> None:
282
+ stat.mark_request_started()
283
+ stat.collecting_enable(self._debugger_enabled_with_pin() or (APPLICATION_DEBUG and APPLICATION_ENV_IS_LOCAL))
284
+
285
+ if encoding := ApiEncoding.from_mime(request.content_encoding):
286
+ request._cached_data = encoding.decode(request.get_data())
287
+
288
+ def _after_request(self, response: Response) -> Response:
289
+ assert self._initialized_flask_name is not None
290
+ if APPLICATION_DEBUG and (APPLICATION_DEBUG_LOGGING or APPLICATION_ENV_IS_LOCAL):
291
+ d = Debugger(self._initialized_flask_name, True, ApiMethod(request.method), request.url)
292
+ d.render_console()
293
+ # if not self._debugger_enabled_with_pin() and APPLICATION_ENV_IS_LOCAL and self._debug_internal_request():
294
+ return response
295
+
296
+ def _debugger_enabled_with_pin(self) -> bool:
297
+ return request.headers.get(REQUEST_HEADER__DEBUGGER) == APPLICATION_DEBUGGER_PIN or request.cookies.get(REQUEST_HEADER__DEBUGGER, "") == APPLICATION_DEBUGGER_PIN
298
+
299
+ def _teardown_request(self, err: Optional[BaseException]) -> None:
300
+ clean_files()
301
+
302
+ def _debug_internal_request(self) -> bool:
303
+ return request.headers.get(REQUEST_HEADER__INTERNAL, '') != REQUEST_HEADER__INTERNAL
304
+
305
+ def url_for(self, fn_or_str: Union[Callable, str], **kwargs: Any) -> str: # type: ignore
306
+ assert self._initialized_flask_name is not None
307
+ if not isinstance(fn_or_str, str):
308
+ fn_or_str = fn_or_str.__name__
309
+ return flask_url_for(fn_or_str, **kwargs)
310
+
311
+ def file_download(
312
+ self,
313
+ method: TMethod,
314
+ path: str,
315
+ *,
316
+ config: Optional[ApiResourceConfig] = None,
317
+ v: ApiPathVersion = ApiPathVersion.V01,
318
+ access: Optional[PermissionDefinition] = None,
319
+ ) -> Callable[[TFn], TFn]:
320
+ assert isinstance(v, ApiPathVersion)
321
+ path = v.compile_path(path, self._config.api_route_path_prefix)
322
+ return self._wrap(ApiResourceType.FILE, method, path, config, access)
323
+
324
+ def html_view(
325
+ self,
326
+ method: TMethod,
327
+ path: str,
328
+ *,
329
+ config: Optional[ApiResourceConfig] = None,
330
+ access: Optional[PermissionDefinition] = None,
331
+ ) -> Callable[[TFn], TFn]:
332
+ return self._wrap(ApiResourceType.WEB, method, path, config, access)
333
+
334
+ def health_check(self) -> Callable[[Callable[[HealthCheckContext], None]], None]:
335
+ return functools.partial(init_health_check_resource, api_sdk=self)
336
+
337
+ def cache_api(
338
+ self,
339
+ mode: TCacheMode,
340
+ tags: Tuple[str, ...] | str,
341
+ timeout: Optional[int] = None,
342
+ source_check: Optional[bool] = True,
343
+ ) -> Callable[[TFn], TFn]:
344
+ if self.ul_cache_factory is None:
345
+ logger.warning("Cache URI is not configured, cache will not work.")
346
+ return lambda fn: fn
347
+ if ULCacheMode.compile_mode(mode) == ULCacheMode.READ.value:
348
+ return self.ul_cache_factory.cache_read_wrap(tags, timeout, source_check)
349
+ return self.ul_cache_factory.cache_refresh_wrap(tags)
350
+
351
+ def rest_api(
352
+ self,
353
+ method: TMethodShort,
354
+ path: str,
355
+ *,
356
+ config: Optional[ApiResourceConfig] = None,
357
+ v: ApiPathVersion = ApiPathVersion.V01,
358
+ access: Optional[PermissionDefinition] = None,
359
+ ) -> Callable[[TFn], TFn]:
360
+ assert isinstance(v, ApiPathVersion)
361
+ path = v.compile_path(path, self._config.api_route_path_prefix)
362
+ return self._wrap(ApiResourceType.API, method, path, config, access)
363
+
364
+ def _wrap(
365
+ self,
366
+ api_type: ApiResourceType,
367
+ method: TMethod,
368
+ path: str,
369
+ api_resource_config: Optional[ApiResourceConfig] = None,
370
+ access: Optional[PermissionDefinition] = None,
371
+ ) -> Callable[[TFn], TFn]:
372
+ config = api_resource_config if api_resource_config is not None else ApiResourceConfig()
373
+ assert isinstance(config, ApiResourceConfig), f'config must be ApiResourceConfig. "{type(config).__name__}" was given'
374
+ access = GLOBAL_PERMISSION__PRIVATE if access is None else access
375
+
376
+ assert isinstance(access, PermissionDefinition)
377
+ if access not in (GLOBAL_PERMISSION__PRIVATE, GLOBAL_PERMISSION__PUBLIC, GLOBAL_PERMISSION__PRIVATE_RT):
378
+ assert isinstance(self._config.permissions, PermissionRegistry)
379
+ assert self._config.permissions.has(access)
380
+
381
+ flask_methods, enum_methods = ApiMethod.compile_methods(method)
382
+
383
+ def wrap(fn: TFn) -> TFn:
384
+ assert self._initialized_flask_name is not None, 'app must be initialized'
385
+ assert fn.__module__, 'empty __module__ of function'
386
+
387
+ fn_module = fn.__module__
388
+ fn_name = fn.__name__
389
+
390
+ logger = logging.getLogger(fn.__module__)
391
+
392
+ fn_typing = ApiResourceFnTyping.parse_fn(api_type, enum_methods, fn)
393
+
394
+ @wraps(fn)
395
+ def wrapper(**kwargs: TKwargs) -> Tuple[BaseResponse, int]:
396
+ now = time.time()
397
+ assert access is not None # for mypy
398
+ debugger_enabled = self._debugger_enabled_with_pin()
399
+
400
+ api_resource = ApiResource(
401
+ logger=logger,
402
+ debugger_enabled=debugger_enabled,
403
+ access=access,
404
+ type=fn_typing.api_resource_type,
405
+ headers=request.headers,
406
+ config=self._config,
407
+ api_resource_config=config,
408
+ fn_typing=fn_typing,
409
+ limiter_enabled=self._limiter_enabled,
410
+ db_initialized=self._db is not None,
411
+ )
412
+
413
+ assert self._initialized_flask_name is not None # just for mypy
414
+ d = Debugger(self._initialized_flask_name, debugger_enabled, ApiMethod(request.method), request.url)
415
+
416
+ scope_user_id = None
417
+ scope_token_id = None
418
+
419
+ res: Optional[Tuple[BaseResponse, int]] = None
420
+
421
+ with sentry.configure_scope() as sentry_scope:
422
+ sentry_scope.set_tag('app_name', self._initialized_flask_name)
423
+ sentry_scope.set_tag('app_type', 'api')
424
+ sentry_scope.set_tag('app_uptime', f'{(datetime.now() - APPLICATION_START_DT).seconds // 60}s')
425
+ sentry_scope.set_tag('app_api_name', fn_name)
426
+ sentry_scope.set_tag('app_api_access', access.id)
427
+
428
+ try:
429
+ with internal_api_check_context():
430
+ result = None
431
+
432
+ if result is None:
433
+ try:
434
+ api_resource._internal_use__check_access(self._get_auth_token())
435
+ if access is not GLOBAL_PERMISSION__PUBLIC:
436
+ scope_user_id = api_resource.auth_token.user_id
437
+ scope_token_id = api_resource.auth_token.id
438
+ sentry_scope.set_user({
439
+ 'id': api_resource.auth_token.user_id,
440
+ 'username': api_resource.auth_token.username,
441
+ })
442
+ except Exception as e: # noqa: B902
443
+ if config.exc_handler_access is None:
444
+ raise
445
+ result = config.exc_handler_access(e)
446
+
447
+ if result is None:
448
+ try:
449
+ kwargs = fn_typing.runtime_validate_request_input(ApiMethod(request.method.upper()), kwargs)
450
+ except Exception as e: # noqa: B902
451
+ if config.exc_handler_bad_request is None:
452
+ raise
453
+ result = config.exc_handler_bad_request(e)
454
+
455
+ if result is None:
456
+ try:
457
+ result = self._flask_app.ensure_sync(fn)(api_resource, **kwargs)
458
+ except Exception as e: # noqa: B902
459
+ if config.exc_handler_endpoint is None:
460
+ raise
461
+ result = config.exc_handler_endpoint(e)
462
+
463
+ if not isinstance(result, ApiResponse):
464
+ raise ResponseTypeRuntimeApiError(f'invalid type of response. must be instance of ApiResponse. {repr(result)} was given')
465
+ if isinstance(result, JsonApiResponse):
466
+ payload, total_count = fn_typing.runtime_validate_api_response_payload(result.payload, result.total_count, quick=False)
467
+ result.payload = payload
468
+ result.total_count = total_count
469
+ elif isinstance(result, RootJsonApiResponse):
470
+ payload, _1 = fn_typing.runtime_validate_api_response_payload(result.root, 0, quick=False)
471
+ result.root = payload
472
+ elif isinstance(result, ProxyJsonApiResponse):
473
+ fn_typing.runtime_validate_api_proxy_payload(result.response, quick=False)
474
+ if len(api_resource._internal_use__files_to_clean) > 0:
475
+ add_files_to_clean(api_resource._internal_use__files_to_clean)
476
+ res = result.to_flask_response(d), result.status_code
477
+ except Exception as e: # noqa: B902
478
+ tb = traceback.format_exc()
479
+ if not isinstance(e, UserAbstractApiError):
480
+ with stat.measure('') as mes:
481
+ mes.add_error(tb)
482
+ if APPLICATION_DEBUG or APPLICATION_ENV_IS_LOCAL: # MAY BE IT SHOULD BE ENABLED BY DEFAULT ?
483
+ print(tb) # noqa
484
+ if not APPLICATION_ENV_IS_LOCAL:
485
+ sentry.capture_exception(e)
486
+ if api_resource._type == ApiResourceType.API:
487
+ result = api_resource.response_api_error(e)
488
+ elif api_resource._type == ApiResourceType.WEB:
489
+ result = api_resource.response_web_error(e)
490
+ elif api_resource._type == ApiResourceType.FILE:
491
+ result = api_resource.response_api_error(e)
492
+ else:
493
+ result = api_resource.response_web_error( # type: ignore
494
+ ResourceRuntimeApiError(f'unsupported type "{api_resource._type.value}" of error response'),
495
+ )
496
+ res = result.to_flask_response(d), result.status_code
497
+ sentry_scope.clear()
498
+ if api_resource_config is not None and api_resource_config.override_flask_response is not None:
499
+ res = api_resource_config.override_flask_response(res)
500
+
501
+ ri = api_resource.request_info
502
+ # TODO: add cache hit flag
503
+ logger.info('AUDIT ' + json.dumps({
504
+ 'user_id': str(scope_user_id) if scope_user_id else None,
505
+ 'token_id': str(scope_token_id) if scope_token_id else None,
506
+ 'method': request.method,
507
+ 'url': request.url,
508
+ 'ipv4': ri.ipv4,
509
+ 'user_agent': ri.user_agent,
510
+ 'duration': time.time() - now,
511
+ 'status_code': res[1] if res is not None else 500,
512
+ 'fn_id': fn_name,
513
+ 'fn_mdl': fn_module,
514
+ 'error_code': _get_error_types(result),
515
+ }))
516
+
517
+ return res
518
+
519
+ assert access is not None # for mypy
520
+ self._fn_registry.append(ApiSdkResource(
521
+ path=path,
522
+ config=config,
523
+ wrapper_fn=wrapper, # type: ignore
524
+ methods=enum_methods,
525
+ fn_typing=fn_typing,
526
+ access=access,
527
+ ))
528
+
529
+ wrapper = self._flask_app.route(path, methods=flask_methods)(wrapper)
530
+
531
+ return cast(TFn, wrapper)
532
+ return wrap
533
+
534
+ @cached_per_request('_api_utils__get_auth_token')
535
+ def _get_auth_token(self) -> Optional[Tuple[Optional[str], str]]:
536
+ auth_header = request.headers.get('Authorization')
537
+ if not auth_header:
538
+ return None
539
+ if not auth_header.startswith('Bearer'):
540
+ value = auth_header.encode('utf-8')
541
+ try:
542
+ _scheme, credentials = value.split(b' ', 1)
543
+ encoded_username, encoded_password = b64decode(credentials).split(b':', 1)
544
+ except (ValueError, TypeError):
545
+ return None
546
+ try:
547
+ return encoded_username.decode('utf-8'), encoded_password.decode('utf-8')
548
+ except UnicodeDecodeError:
549
+ return encoded_username.decode('latin1'), encoded_password.decode('latin1')
550
+ except Exception: # noqa: B902
551
+ return None
552
+ auth_segm = auth_header.split(" ")
553
+ if len(auth_segm) < 2:
554
+ return None
555
+ return None, auth_segm[1]
@@ -0,0 +1,63 @@
1
+ from enum import Enum
2
+ from typing import List, Optional, Callable, Union
3
+
4
+ from pydantic import ConfigDict, BaseModel
5
+
6
+ from ul_api_utils.access import PermissionRegistry, PermissionDefinition
7
+ from ul_api_utils.modules.api_sdk_jwt import ApiSdkJwt
8
+ from ul_api_utils.resources.socketio import SocketIOConfig
9
+
10
+
11
+ def join_route_paths(prev_sect: str, next_sect: str) -> str:
12
+ return prev_sect.rstrip('/') + '/' + next_sect.lstrip('/')
13
+
14
+
15
+ class ApiSdkIdentifyTypeEnum(Enum):
16
+ DISABLED = 'DISABLED'
17
+ CLIENT_IP = 'IP'
18
+ JWT_USER_ID = 'JWT_USER_ID'
19
+
20
+ def __repr__(self) -> str:
21
+ return f'{type(self).__name__}.{self.name}'
22
+
23
+
24
+ class ApiSdkHttpAuth(BaseModel):
25
+ realm: str = 'Hidden Zone'
26
+ scheme: str = 'Basic'
27
+
28
+
29
+ class ApiSdkFlaskDebuggingPluginsEnabled(BaseModel):
30
+ flask_monitoring_dashboard: bool = False
31
+
32
+
33
+ class ApiSdkConfig(BaseModel):
34
+ service_name: str
35
+ permissions: Optional[Union[Callable[[], PermissionRegistry], PermissionRegistry]] = None
36
+ permissions_check_enabled: bool = True # GLOBAL CHECK OF ACCESS AND PERMISSIONS ENABLE
37
+ permissions_validator: Optional[Callable[[ApiSdkJwt, PermissionDefinition], bool]] = None
38
+
39
+ jwt_validator: Optional[Callable[[ApiSdkJwt], bool]] = None
40
+ jwt_environment_check_enabled: bool = True
41
+
42
+ http_auth: Optional[ApiSdkHttpAuth] = None
43
+
44
+ static_url_path: Optional[str] = None
45
+ socket_config: Optional[SocketIOConfig] = None
46
+ web_error_template: Optional[str] = None
47
+
48
+ rate_limit: Union[str, List[str]] = '100/minute' # [count (int)] [per|/] [second|minute|hour|day|month|year][s]
49
+ rate_limit_storage_uri: str = '' # supports url of redis, memcached, mongodb
50
+ rate_limit_identify: Union[ApiSdkIdentifyTypeEnum, Callable[[], str]] = ApiSdkIdentifyTypeEnum.DISABLED # must be None if disabled
51
+
52
+ cache_storage_uri: str = '' # supports only redis
53
+ cache_default_ttl: int = 60 # seconds
54
+
55
+ flask_debugging_plugins: Optional[ApiSdkFlaskDebuggingPluginsEnabled] = None
56
+
57
+ api_route_path_prefix: str = '/api'
58
+
59
+ model_config = ConfigDict(
60
+ extra="forbid",
61
+ frozen=True,
62
+ arbitrary_types_allowed=True,
63
+ )