py-app-runner 0.5.49.dev0__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 (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,604 @@
1
+ import datetime
2
+ import hashlib
3
+ import hmac
4
+ import inspect
5
+ import logging
6
+ from asyncio import Future
7
+ from collections.abc import Callable
8
+ from time import time
9
+ from typing import Any, Literal
10
+ from uuid import UUID
11
+
12
+ from database_wrapper_pgsql import DBWrapperPgsqlAsync, PgConnectionTypeAsync, PgCursorTypeAsync
13
+ from redis.asyncio import Redis as RedisClientAsync
14
+ from tornado import httputil, web
15
+
16
+ from py_app_runner.registry import AppRegistry
17
+
18
+ from ..db_pools import DbPools
19
+ from ..http_exception import HTTPException
20
+ from ..pybridge import PyBridge
21
+ from ..return_model import MessageModel, ReturnModel, StatusModel
22
+ from ..timer import Timer
23
+ from ..utils import (
24
+ format_body_for_log,
25
+ format_response_for_log,
26
+ json_decode,
27
+ json_encode_bytes,
28
+ redact_headers,
29
+ safe_decode,
30
+ sha1_prefix,
31
+ )
32
+ from ..wbcm.wb_connection_manager import WbConnectionManager
33
+ from .auth_service import AuthService
34
+
35
+ BridgeRequestData = dict[str, Any]
36
+ BridgeRequest = Callable[[str, BridgeRequestData, "RequestHandlerBase"], Any]
37
+ RequiresApiKey = bool | None
38
+ RequestParams = dict[str, Any]
39
+
40
+
41
+ class WebApplicationBase(web.Application):
42
+ """Tornado web application expansion to initialize stuff like database connections and/or sessions"""
43
+
44
+ pybridge: PyBridge
45
+ db_pools: DbPools
46
+ wb_connection_manager: WbConnectionManager
47
+
48
+ def set_pybridge(self, pybridge: PyBridge) -> None:
49
+ """Set pybridge"""
50
+ self.pybridge = pybridge
51
+
52
+ def set_db_pools(self, db_pools: DbPools) -> None:
53
+ """Set database pools"""
54
+ self.db_pools = db_pools
55
+
56
+ def set_wb_connection_manager(self, wb_connection_manager: WbConnectionManager) -> None:
57
+ """Set WebBridge connection manager"""
58
+ self.wb_connection_manager = wb_connection_manager
59
+
60
+
61
+ class RequestHandlerBase(web.RequestHandler):
62
+ """Base request handler"""
63
+
64
+ ####################
65
+ ### Properties #####
66
+ ####################
67
+
68
+ # Instance variables
69
+ logger_name: str
70
+ logger: logging.Logger
71
+ timer: Timer
72
+
73
+ # Temporary variables
74
+ _current_user_obj: Any | None
75
+ _impersonator_obj: Any | None
76
+ json_args: dict[str, Any] | None
77
+ require_json_response: bool
78
+ module = None
79
+ uid: str | None
80
+ auth_token: str | None
81
+
82
+ _context: dict[str, Any]
83
+
84
+ auth_service: AuthService
85
+
86
+ # Overrides
87
+ application: WebApplicationBase # type: ignore[assignment]
88
+
89
+ @property
90
+ def pybridge(self) -> PyBridge:
91
+ return self.application.pybridge
92
+
93
+ @property
94
+ def db_pools(self) -> DbPools:
95
+ return self.application.db_pools
96
+
97
+ @property
98
+ def wb_connection_manager(self) -> WbConnectionManager:
99
+ return self.application.wb_connection_manager
100
+
101
+ @property
102
+ def context(self) -> RequestParams:
103
+ raise NotImplementedError
104
+
105
+ #########################
106
+ ### Class lifecycle #####
107
+ #########################
108
+
109
+ def __init__(
110
+ self,
111
+ application: WebApplicationBase,
112
+ request: httputil.HTTPServerRequest,
113
+ **kwargs: Any,
114
+ ):
115
+ self.logger_name = f"{__name__}.{self.__class__.__name__}"
116
+ self.logger = logging.getLogger(self.logger_name)
117
+ self.timer = Timer("WebHandlerBase")
118
+ self.require_json_response = False
119
+ self.json_args = None
120
+ self.uid = None
121
+ self.auth_token = None
122
+ self.auth_service = AuthService(self.logger)
123
+
124
+ super().__init__(application, request, **kwargs)
125
+
126
+ #################
127
+ ### Helpers #####
128
+ #################
129
+
130
+ def find_service(self, service_name: str | None) -> ReturnModel[tuple[BridgeRequest | None, RequiresApiKey | None]]:
131
+ if service_name is None:
132
+ return ReturnModel(error=MessageModel(text="No service was provided.", code=-1000))
133
+
134
+ service_name = service_name.replace("-", "_")
135
+ if service_name not in self.pybridge.services_placeholder:
136
+ return ReturnModel(error=MessageModel(text=f'Module "{service_name}" not found.', code=-1010))
137
+
138
+ service = self.pybridge.services_placeholder[service_name]
139
+ bridge_request = getattr(service, "bridge_request", None)
140
+ if not callable(bridge_request):
141
+ return ReturnModel(
142
+ error=MessageModel(text=f'"{service_name}" does not support bridge requests', code=-1020)
143
+ )
144
+
145
+ # Check if there is an API key required method
146
+ requires_api_key = getattr(service, "requires_api_key", None)
147
+
148
+ return ReturnModel(
149
+ result=(
150
+ bridge_request,
151
+ requires_api_key,
152
+ ),
153
+ )
154
+
155
+ def get_request_data(self, force_action: str | None = None) -> dict[str, Any]:
156
+ query_params = self.request.query_arguments
157
+ decoded_params: dict[str, Any] = {}
158
+
159
+ for k, v in query_params.items():
160
+ # Check if there's only one item in the list
161
+ if len(v) == 1:
162
+ decoded_params[k] = v[0].decode("utf-8")
163
+ else:
164
+ # Decode each item in the list
165
+ decoded_params[k] = [item.decode("utf-8") for item in v]
166
+
167
+ request_body = {}
168
+ if hasattr(self, "json_args") and self.json_args:
169
+ request_body = self.json_args
170
+
171
+ if force_action:
172
+ request_body["action"] = force_action
173
+
174
+ return {**decoded_params, **request_body}
175
+
176
+ def error(
177
+ self,
178
+ msg: str | HTTPException,
179
+ code: int = -1,
180
+ http_status: int = 400,
181
+ ) -> None: ...
182
+
183
+ def log_request(
184
+ self,
185
+ *,
186
+ response: Any | None = None,
187
+ error: Exception | None = None,
188
+ level: str = "info",
189
+ include_body: bool = True,
190
+ max_field_chars: int = 4000,
191
+ shrink_strings_to: int = 256,
192
+ cap_list_items: int = 50,
193
+ ) -> None:
194
+ """
195
+ Unified request logger.
196
+
197
+ - If `error` provided -> logs at WARNING by default (override with `level`).
198
+ - If `response` provided -> logs a trimmed response.
199
+ - No field-name assumptions; generic shrink/trim for huge payloads.
200
+ - Set `include_body=False` to avoid logging potentially massive bodies (keeps only a summary).
201
+ """
202
+ logger: logging.Logger = getattr(self, "logger", logging.getLogger(__name__))
203
+
204
+ # choose level
205
+ level = (level or "").lower()
206
+ if error and level == "info":
207
+ level = "warning"
208
+ log = getattr(logger, level, logger.info)
209
+
210
+ req = getattr(self, "request", None)
211
+ method = getattr(req, "method", "?")
212
+ path = getattr(req, "path", getattr(req, "uri", "?"))
213
+ headers = redact_headers({str(k): v for k, v in dict(getattr(req, "headers", {}) or {}).items()})
214
+ args = getattr(req, "arguments", None)
215
+ body_raw = getattr(req, "body", b"")
216
+
217
+ # quick body size + hash (without rendering the whole thing)
218
+ if isinstance(body_raw, (bytes, bytearray)):
219
+ body_len = len(body_raw)
220
+ body_short = f"<{body_len} bytes sha1={sha1_prefix(bytes(body_raw))}>"
221
+ else:
222
+ body_text = safe_decode(body_raw)
223
+ body_len = len(body_text)
224
+ body_short = f"<{body_len} chars>"
225
+
226
+ if error:
227
+ log("Got error while processing request: %s", error)
228
+
229
+ # concise one-liner
230
+ log(
231
+ "Request %s %s | len(body)=%s | args=%s",
232
+ method,
233
+ path,
234
+ body_len,
235
+ args if isinstance(args, dict) else "<n/a>", # type: ignore
236
+ )
237
+
238
+ # details go at DEBUG unless we're already warning/error, then use same level
239
+ detail_log = logger.debug if level in ("info", "debug") else log
240
+
241
+ detail_log("Headers: %s", headers)
242
+
243
+ if include_body:
244
+ body_for_log = format_body_for_log(
245
+ body_raw,
246
+ max_chars=max_field_chars,
247
+ shrink_strings_to=shrink_strings_to,
248
+ cap_list_items=cap_list_items,
249
+ )
250
+ detail_log("Body: %s", body_for_log)
251
+ else:
252
+ detail_log("Body: %s", body_short)
253
+
254
+ if response is not None:
255
+ resp_for_log = format_response_for_log(
256
+ response,
257
+ max_chars=max_field_chars,
258
+ shrink_strings_to=shrink_strings_to,
259
+ cap_list_items=cap_list_items,
260
+ )
261
+ detail_log("Response: %s", resp_for_log)
262
+
263
+ ###############
264
+ ### Prepare ###
265
+ ###############
266
+
267
+ async def prepare(self):
268
+ res = super().prepare()
269
+ if inspect.isawaitable(res):
270
+ await res
271
+
272
+ # Debug
273
+ headers = dict(self.request.headers)
274
+ headers.pop("Authorization", None)
275
+
276
+ self.logger.debug(f"Request: {self.request}")
277
+ self.logger.debug(f"Headers: {headers}")
278
+ self.logger.debug(f"Body: {self.request.body!r}")
279
+ self.logger.debug(f"Arguments: {self.request.arguments.keys()}")
280
+
281
+ self._ensure_content_type()
282
+ await self._ensure_current_user()
283
+
284
+ def _ensure_content_type(self):
285
+ # Content-Type
286
+ content_type = self.request.headers.get("Content-Type", "") # type: ignore
287
+ if (
288
+ content_type.startswith("application/json") or content_type.startswith("application/x-json")
289
+ ) and self.request.body:
290
+ try:
291
+ self.json_args = json_decode(self.request.body)
292
+
293
+ except Exception as e:
294
+ self.log_request(error=e)
295
+ self.error("Invalid JSON body", 400, http_status=400)
296
+ self.finish()
297
+ return
298
+
299
+ # What to expect in return
300
+ accept = self.request.headers.get("Accept", "application/json") # type: ignore
301
+ if accept == "application/json":
302
+ self.set_header("Content-Type", "application/json; charset=UTF-8")
303
+ self.require_json_response = True
304
+ else:
305
+ self.set_header("Content-Type", "text/html; charset=UTF-8")
306
+
307
+ def _clear_current_user(self) -> None:
308
+ """Drop the request's user and, for WebSockets, the connection's user binding.
309
+
310
+ Leaving the binding in place would keep an unauthenticated (or re-authenticated)
311
+ socket in the previous user's fan-out list.
312
+ """
313
+ self._current_user_obj = None
314
+
315
+ uid = getattr(self, "uid", None)
316
+ if uid:
317
+ self.wb_connection_manager.user_connections.unassign_connection(uid)
318
+
319
+ async def _ensure_current_user(self):
320
+ # Run BEFORE get/post/etc.
321
+ self._impersonator_obj = None
322
+
323
+ # Clear Tornado's cached current_user so the property re-evaluates.
324
+ # Critical for WebSocket handlers where the same instance handles
325
+ # multiple messages and the token can change between them.
326
+ if hasattr(self, "_current_user"):
327
+ del self._current_user
328
+
329
+ token = self.auth_token
330
+ if not token:
331
+ token = self.request.headers.get("Authorization", "")
332
+ if token.startswith("Bearer "):
333
+ token = token.removeprefix("Bearer ").strip()
334
+ else:
335
+ token = None
336
+
337
+ if not token:
338
+ self._clear_current_user()
339
+ return
340
+
341
+ self.auth_token = token
342
+
343
+ # Try normal user token first
344
+ payload = self.auth_service.verify_access_jwt(token, expected_type="user")
345
+ is_impersonation = False
346
+
347
+ if not payload:
348
+ # Try impersonation token
349
+ payload = self.auth_service.verify_access_jwt(token, expected_type="impersonation")
350
+ if payload:
351
+ is_impersonation = True
352
+
353
+ if not payload:
354
+ self._clear_current_user()
355
+ return
356
+
357
+ user_public_id = payload["sub"]
358
+
359
+ # Defensive UUID validation
360
+ try:
361
+ UUID(user_public_id)
362
+ except ValueError:
363
+ self._clear_current_user()
364
+ return
365
+
366
+ UsersModel = AppRegistry.users_model()
367
+ async with self.db_pools.main_db_pool as (pg_conn, pg_cur):
368
+ if not pg_cur or not pg_conn:
369
+ raise HTTPException("Database is not initialized", 500, http_status=500)
370
+
371
+ user = await UsersModel.get_by_public_id(user_public_id, pg_cur)
372
+
373
+ if is_impersonation:
374
+ imp_public_id = payload.get("imp")
375
+ if imp_public_id:
376
+ impersonator = await UsersModel.get_by_public_id(imp_public_id, pg_cur)
377
+ if impersonator and not impersonator.disabled_at and not impersonator.deleted_at:
378
+ self._impersonator_obj = impersonator
379
+
380
+ if not user or user.disabled_at or user.deleted_at:
381
+ self._clear_current_user()
382
+ return
383
+
384
+ # Cache it for this request
385
+ self._current_user_obj = user
386
+
387
+ # Tag WS connections with the user id so per-user pushes can reach them
388
+ # without every project re-wiring this in a subclass. HTTP handlers have
389
+ # no `uid`, so this is a no-op for them.
390
+ uid = getattr(self, "uid", None)
391
+ if uid:
392
+ self.wb_connection_manager.user_connections.assign_user_id(uid, str(user.id))
393
+
394
+ @property
395
+ def impersonator(self) -> Any | None:
396
+ """Returns the superadmin identity if current request uses an impersonation token."""
397
+ return getattr(self, "_impersonator_obj", None)
398
+
399
+ def get_current_user(self):
400
+ # Must be sync in Tornado; just return cached value.
401
+ return self._current_user_obj
402
+
403
+
404
+ class RequestHandlerApiKeys(RequestHandlerBase):
405
+ #########################
406
+ ### Validate api keys ###
407
+ #########################
408
+
409
+ def verify_api_key(self, api_key_secret: str, stored_hash: str) -> bool:
410
+ h = hashlib.sha256()
411
+ h.update(AppRegistry.config()["api_key_pepper"].encode())
412
+ h.update(api_key_secret.encode())
413
+ computed = h.hexdigest()
414
+
415
+ return hmac.compare_digest(computed, stored_hash)
416
+
417
+ async def has_valid_key_db(self, api_key: str, db_cur: PgCursorTypeAsync) -> str | Literal[True]:
418
+ api_key_split = api_key.split(".", 1)
419
+ if len(api_key_split) != 2:
420
+ return "Invalid API KEY"
421
+
422
+ api_key_prefix, api_key_secret = api_key_split
423
+
424
+ # Load from db
425
+ async with self.timer.aenter("request_handler.has_valid_key_db.load_from_db"):
426
+ db_wrapper = DBWrapperPgsqlAsync(db_cur)
427
+ api_key_model = AppRegistry.api_keys_model()
428
+ api_key_record = await db_wrapper.get_by_key(api_key_model(), id_key="key_prefix", id_value=api_key_prefix)
429
+
430
+ if not api_key_record:
431
+ return "Invalid API Key"
432
+
433
+ if api_key_record.disabled_at:
434
+ return "API key is disabled"
435
+
436
+ if not self.verify_api_key(api_key_secret, api_key_record.secret_hash):
437
+ return "Invalid API key"
438
+
439
+ async with self.timer.aenter("request_handler.has_valid_key_db.use_key"):
440
+ await api_key_record.use_key(db_cur)
441
+
442
+ return True
443
+
444
+ def has_valid_key(self, api_key: str) -> str | Literal[True]:
445
+ configured_key = AppRegistry.config().get("api", {}).get("key")
446
+ if not configured_key:
447
+ return "API key not configured"
448
+
449
+ # Compare as bytes: compare_digest rejects non-ASCII str, and the key comes
450
+ # straight from the client.
451
+ supplied = api_key.encode("utf-8", errors="replace")
452
+ candidates = configured_key if isinstance(configured_key, list) else [configured_key]
453
+ for candidate in candidates:
454
+ if hmac.compare_digest(supplied, str(candidate).encode("utf-8", errors="replace")):
455
+ return True
456
+
457
+ return "Invalid API key"
458
+
459
+ async def has_valid_api_key(self, custom_api_key: str | None = None) -> str | Literal[True]:
460
+ api_key: str | None = custom_api_key or self.request.headers.get("X-API-Key", None) # type: ignore
461
+ if api_key is None:
462
+ api_key = self.get_argument("api_key", None)
463
+
464
+ if api_key is None:
465
+ return "API key is missing"
466
+
467
+ api_key = str(api_key)
468
+
469
+ if AppRegistry.api_key_use_db():
470
+ async with self.db_pools.main_db_pool as (pg_conn, pg_cur):
471
+ if not pg_cur or not pg_conn:
472
+ raise HTTPException("Database is not initialized")
473
+ async with pg_conn.transaction():
474
+ return await self.has_valid_key_db(api_key, pg_cur)
475
+
476
+ return self.has_valid_key(api_key)
477
+
478
+
479
+ class WebHandlerBase(RequestHandlerApiKeys):
480
+ """Web request handler base"""
481
+
482
+ ##################
483
+ ### Properties ###
484
+ ##################
485
+
486
+ @property
487
+ def context(self) -> RequestParams:
488
+ if not hasattr(self, "_context"):
489
+ self._context = {
490
+ "base_uri": "/",
491
+ "module": self.module,
492
+ "config": AppRegistry.config(),
493
+ "timestamp": int(time()),
494
+ "now": datetime.datetime.now(),
495
+ }
496
+
497
+ return self._context
498
+
499
+ ###########################
500
+ ### Tornado overrides #####
501
+ ###########################
502
+
503
+ def render(self, template_name: str, **kwargs: Any) -> Future[Any]:
504
+ self.context.update(kwargs)
505
+
506
+ if "/" in template_name:
507
+ template_name = f"{self.application.settings['routes_path']}/{template_name}"
508
+
509
+ return super().render(template_name, **self.context)
510
+
511
+ def render_string(self, template_name: str, **kwargs: Any) -> bytes:
512
+ self.context.update(kwargs)
513
+
514
+ return super().render_string(template_name, **self.context)
515
+
516
+ ##########################
517
+ ### Request handling #####
518
+ ##########################
519
+ def write(self, chunk: str | bytes | dict[str, Any], wrap_in_data: bool = True):
520
+ if isinstance(chunk, HTTPException):
521
+ self.error(chunk)
522
+ return
523
+
524
+ if isinstance(chunk, StatusModel) or isinstance(chunk, MessageModel) or isinstance(chunk, ReturnModel):
525
+ chunk = chunk.to_dict()
526
+
527
+ if wrap_in_data:
528
+ chunk = {"data": chunk}
529
+
530
+ if isinstance(chunk, dict) or isinstance(chunk, list):
531
+ response = json_encode_bytes(chunk, pretty=self.application.settings.get("debug", False))
532
+
533
+ self.set_header("Content-Type", "application/json; charset=UTF-8")
534
+ super().write(response) # type: ignore
535
+ else:
536
+ super().write(chunk) # type: ignore
537
+
538
+ def error(
539
+ self,
540
+ msg: str | HTTPException,
541
+ code: int = -1,
542
+ http_status: int = 400,
543
+ ) -> None:
544
+ # `data` is the sole payload container - errors are wrapped like every other
545
+ # response, so HTTP and WebSocket clients parse the same shape.
546
+ if isinstance(msg, HTTPException):
547
+ self.set_status(msg.http_status)
548
+ self.write({"error": msg.to_dict()})
549
+ return
550
+
551
+ if http_status:
552
+ self.set_status(http_status)
553
+
554
+ self.write({"error": {"code": code, "msg": msg}})
555
+
556
+
557
+ class RequestHandlerHelper:
558
+ _action_name: str
559
+
560
+ bridge_handler: RequestHandlerBase
561
+
562
+ pg_conn: PgConnectionTypeAsync
563
+ pg_cur: PgCursorTypeAsync
564
+ db_wrapper: DBWrapperPgsqlAsync
565
+ redis_con: RedisClientAsync
566
+
567
+ # Cache for registered actions per class to avoid re-scanning
568
+ _action_registry: dict[type, dict[str, Callable[..., Any]]] = {}
569
+
570
+ @classmethod
571
+ def _get_actions(cls) -> dict[str, Callable[..., Any]]:
572
+ """Lazy load and cache actions for the class"""
573
+ if cls not in cls._action_registry:
574
+ registry = {}
575
+ # Inspect all members of the class
576
+ for attr_name in dir(cls):
577
+ method = getattr(cls, attr_name)
578
+ # Check if it has our decorator tag
579
+ if hasattr(method, "_action_name"):
580
+ registry[method._action_name] = method
581
+ cls._action_registry[cls] = registry
582
+ return cls._action_registry[cls]
583
+
584
+ async def handle_request(
585
+ self,
586
+ action: str,
587
+ input_data: dict[str, Any],
588
+ bridge_handler: RequestHandlerBase,
589
+ ) -> Any:
590
+ """
591
+ Generic dispatcher that looks up the action in the registry.
592
+ """
593
+ self.bridge_handler = bridge_handler
594
+ registry = self._get_actions()
595
+ handler_method = registry.get(action)
596
+
597
+ if not handler_method:
598
+ handler_method = registry.get("default")
599
+
600
+ if not handler_method:
601
+ raise HTTPException(f"Unknown action `{action}`", 1234)
602
+
603
+ # Call the method
604
+ return await handler_method(self, input_data)
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ DEFAULT_PAGE_LIMIT = 50
4
+ MAX_PAGE_LIMIT = 100
5
+
6
+
7
+ def parse_pagination(input_data: dict[str, Any] | None, default_limit: int = DEFAULT_PAGE_LIMIT) -> tuple[int, int]:
8
+ """Extract and clamp limit/offset from client input."""
9
+ if not input_data:
10
+ return default_limit, 0
11
+
12
+ try:
13
+ limit = int(input_data.get("limit", default_limit))
14
+ except (TypeError, ValueError):
15
+ limit = default_limit
16
+
17
+ try:
18
+ offset = int(input_data.get("offset", 0))
19
+ except (TypeError, ValueError):
20
+ offset = 0
21
+
22
+ limit = max(1, min(limit, MAX_PAGE_LIMIT))
23
+ offset = max(0, offset)
24
+ return limit, offset
@@ -0,0 +1,78 @@
1
+ from typing import Any, Generic, TypeVar
2
+
3
+ T = TypeVar("T")
4
+
5
+
6
+ class StatusModel:
7
+ status: str
8
+ message: str | None
9
+
10
+ def __init__(self, status: str = "ok", message: str | None = None):
11
+ self.status = status
12
+ self.message = message
13
+
14
+ def __str__(self) -> str:
15
+ return str(self.to_dict())
16
+
17
+ def __repr__(self) -> str:
18
+ return self.__str__()
19
+
20
+ def to_dict(self) -> dict[str, Any]:
21
+ newDict = {"status": self.status}
22
+ if self.message:
23
+ newDict["message"] = self.message
24
+ return newDict
25
+
26
+
27
+ class MessageModel:
28
+ """
29
+ MessageModel
30
+ """
31
+
32
+ code: int
33
+ text: str
34
+
35
+ def __init__(self, text: str, code: int = 0):
36
+ self.text = text
37
+ self.code = code
38
+
39
+ def __str__(self) -> str:
40
+ return f"code: {self.code} | message: {self.text}"
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "code": self.code,
45
+ "text": self.text,
46
+ }
47
+
48
+
49
+ class ReturnModel(Generic[T]):
50
+ """
51
+ ReturnModel
52
+ """
53
+
54
+ result: T | None
55
+ error: MessageModel | None
56
+
57
+ # Additional info
58
+ info: Any | None
59
+
60
+ def __init__(
61
+ self,
62
+ result: T | None = None,
63
+ error: MessageModel | None = None,
64
+ info: Any | None = None,
65
+ ):
66
+ self.result = result
67
+ self.error = error
68
+ self.info = info
69
+
70
+ def __str__(self) -> str:
71
+ return f"result: {self.result} | error message: {self.error} | info: {self.info}"
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {
75
+ "result": self.result,
76
+ "error": self.error.to_dict() if self.error else None,
77
+ "info": self.info,
78
+ }