tigrbl 0.0.1.dev1__py3-none-any.whl → 0.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 (269) hide show
  1. tigrbl/README.md +94 -0
  2. tigrbl/__init__.py +139 -14
  3. tigrbl/api/__init__.py +6 -0
  4. tigrbl/api/_api.py +97 -0
  5. tigrbl/api/api_spec.py +30 -0
  6. tigrbl/api/mro_collect.py +43 -0
  7. tigrbl/api/shortcuts.py +56 -0
  8. tigrbl/api/tigrbl_api.py +291 -0
  9. tigrbl/app/__init__.py +0 -0
  10. tigrbl/app/_app.py +86 -0
  11. tigrbl/app/_model_registry.py +41 -0
  12. tigrbl/app/app_spec.py +42 -0
  13. tigrbl/app/mro_collect.py +67 -0
  14. tigrbl/app/shortcuts.py +65 -0
  15. tigrbl/app/tigrbl_app.py +319 -0
  16. tigrbl/bindings/__init__.py +73 -0
  17. tigrbl/bindings/api/__init__.py +12 -0
  18. tigrbl/bindings/api/common.py +109 -0
  19. tigrbl/bindings/api/include.py +256 -0
  20. tigrbl/bindings/api/resource_proxy.py +149 -0
  21. tigrbl/bindings/api/rpc.py +111 -0
  22. tigrbl/bindings/columns.py +49 -0
  23. tigrbl/bindings/handlers/__init__.py +11 -0
  24. tigrbl/bindings/handlers/builder.py +119 -0
  25. tigrbl/bindings/handlers/ctx.py +74 -0
  26. tigrbl/bindings/handlers/identifiers.py +228 -0
  27. tigrbl/bindings/handlers/namespaces.py +51 -0
  28. tigrbl/bindings/handlers/steps.py +276 -0
  29. tigrbl/bindings/hooks.py +311 -0
  30. tigrbl/bindings/model.py +194 -0
  31. tigrbl/bindings/model_helpers.py +139 -0
  32. tigrbl/bindings/model_registry.py +77 -0
  33. tigrbl/bindings/rest/__init__.py +7 -0
  34. tigrbl/bindings/rest/attach.py +34 -0
  35. tigrbl/bindings/rest/collection.py +286 -0
  36. tigrbl/bindings/rest/common.py +120 -0
  37. tigrbl/bindings/rest/fastapi.py +76 -0
  38. tigrbl/bindings/rest/helpers.py +119 -0
  39. tigrbl/bindings/rest/io.py +317 -0
  40. tigrbl/bindings/rest/io_headers.py +49 -0
  41. tigrbl/bindings/rest/member.py +386 -0
  42. tigrbl/bindings/rest/router.py +296 -0
  43. tigrbl/bindings/rest/routing.py +153 -0
  44. tigrbl/bindings/rpc.py +364 -0
  45. tigrbl/bindings/schemas/__init__.py +11 -0
  46. tigrbl/bindings/schemas/builder.py +348 -0
  47. tigrbl/bindings/schemas/defaults.py +260 -0
  48. tigrbl/bindings/schemas/utils.py +193 -0
  49. tigrbl/column/README.md +62 -0
  50. tigrbl/column/__init__.py +72 -0
  51. tigrbl/column/_column.py +96 -0
  52. tigrbl/column/column_spec.py +40 -0
  53. tigrbl/column/field_spec.py +31 -0
  54. tigrbl/column/infer/__init__.py +25 -0
  55. tigrbl/column/infer/core.py +92 -0
  56. tigrbl/column/infer/jsonhints.py +44 -0
  57. tigrbl/column/infer/planning.py +133 -0
  58. tigrbl/column/infer/types.py +102 -0
  59. tigrbl/column/infer/utils.py +59 -0
  60. tigrbl/column/io_spec.py +136 -0
  61. tigrbl/column/mro_collect.py +59 -0
  62. tigrbl/column/shortcuts.py +89 -0
  63. tigrbl/column/storage_spec.py +65 -0
  64. tigrbl/config/__init__.py +19 -0
  65. tigrbl/config/constants.py +224 -0
  66. tigrbl/config/defaults.py +29 -0
  67. tigrbl/config/resolver.py +295 -0
  68. tigrbl/core/__init__.py +47 -0
  69. tigrbl/core/crud/__init__.py +36 -0
  70. tigrbl/core/crud/bulk.py +168 -0
  71. tigrbl/core/crud/helpers/__init__.py +76 -0
  72. tigrbl/core/crud/helpers/db.py +92 -0
  73. tigrbl/core/crud/helpers/enum.py +86 -0
  74. tigrbl/core/crud/helpers/filters.py +162 -0
  75. tigrbl/core/crud/helpers/model.py +123 -0
  76. tigrbl/core/crud/helpers/normalize.py +99 -0
  77. tigrbl/core/crud/ops.py +235 -0
  78. tigrbl/ddl/__init__.py +344 -0
  79. tigrbl/decorators.py +17 -0
  80. tigrbl/deps/__init__.py +20 -0
  81. tigrbl/deps/fastapi.py +45 -0
  82. tigrbl/deps/favicon.svg +4 -0
  83. tigrbl/deps/jinja.py +27 -0
  84. tigrbl/deps/pydantic.py +10 -0
  85. tigrbl/deps/sqlalchemy.py +94 -0
  86. tigrbl/deps/starlette.py +36 -0
  87. tigrbl/engine/__init__.py +45 -0
  88. tigrbl/engine/_engine.py +144 -0
  89. tigrbl/engine/bind.py +33 -0
  90. tigrbl/engine/builders.py +236 -0
  91. tigrbl/engine/capabilities.py +29 -0
  92. tigrbl/engine/collect.py +111 -0
  93. tigrbl/engine/decorators.py +110 -0
  94. tigrbl/engine/docs/PLUGINS.md +49 -0
  95. tigrbl/engine/engine_spec.py +355 -0
  96. tigrbl/engine/plugins.py +52 -0
  97. tigrbl/engine/registry.py +36 -0
  98. tigrbl/engine/resolver.py +224 -0
  99. tigrbl/engine/shortcuts.py +216 -0
  100. tigrbl/hook/__init__.py +21 -0
  101. tigrbl/hook/_hook.py +22 -0
  102. tigrbl/hook/decorators.py +28 -0
  103. tigrbl/hook/hook_spec.py +24 -0
  104. tigrbl/hook/mro_collect.py +98 -0
  105. tigrbl/hook/shortcuts.py +44 -0
  106. tigrbl/hook/types.py +76 -0
  107. tigrbl/op/__init__.py +50 -0
  108. tigrbl/op/_op.py +31 -0
  109. tigrbl/op/canonical.py +31 -0
  110. tigrbl/op/collect.py +11 -0
  111. tigrbl/op/decorators.py +238 -0
  112. tigrbl/op/model_registry.py +301 -0
  113. tigrbl/op/mro_collect.py +99 -0
  114. tigrbl/op/resolver.py +216 -0
  115. tigrbl/op/types.py +136 -0
  116. tigrbl/orm/__init__.py +1 -0
  117. tigrbl/orm/mixins/_RowBound.py +83 -0
  118. tigrbl/orm/mixins/__init__.py +95 -0
  119. tigrbl/orm/mixins/bootstrappable.py +113 -0
  120. tigrbl/orm/mixins/bound.py +47 -0
  121. tigrbl/orm/mixins/edges.py +40 -0
  122. tigrbl/orm/mixins/fields.py +165 -0
  123. tigrbl/orm/mixins/hierarchy.py +54 -0
  124. tigrbl/orm/mixins/key_digest.py +44 -0
  125. tigrbl/orm/mixins/lifecycle.py +115 -0
  126. tigrbl/orm/mixins/locks.py +51 -0
  127. tigrbl/orm/mixins/markers.py +16 -0
  128. tigrbl/orm/mixins/operations.py +57 -0
  129. tigrbl/orm/mixins/ownable.py +337 -0
  130. tigrbl/orm/mixins/principals.py +98 -0
  131. tigrbl/orm/mixins/tenant_bound.py +301 -0
  132. tigrbl/orm/mixins/upsertable.py +118 -0
  133. tigrbl/orm/mixins/utils.py +49 -0
  134. tigrbl/orm/tables/__init__.py +72 -0
  135. tigrbl/orm/tables/_base.py +8 -0
  136. tigrbl/orm/tables/audit.py +56 -0
  137. tigrbl/orm/tables/client.py +25 -0
  138. tigrbl/orm/tables/group.py +29 -0
  139. tigrbl/orm/tables/org.py +30 -0
  140. tigrbl/orm/tables/rbac.py +76 -0
  141. tigrbl/orm/tables/status.py +106 -0
  142. tigrbl/orm/tables/tenant.py +22 -0
  143. tigrbl/orm/tables/user.py +39 -0
  144. tigrbl/response/README.md +34 -0
  145. tigrbl/response/__init__.py +33 -0
  146. tigrbl/response/bind.py +12 -0
  147. tigrbl/response/decorators.py +37 -0
  148. tigrbl/response/resolver.py +83 -0
  149. tigrbl/response/shortcuts.py +171 -0
  150. tigrbl/response/types.py +49 -0
  151. tigrbl/rest/__init__.py +27 -0
  152. tigrbl/runtime/README.md +129 -0
  153. tigrbl/runtime/__init__.py +20 -0
  154. tigrbl/runtime/atoms/__init__.py +102 -0
  155. tigrbl/runtime/atoms/emit/__init__.py +42 -0
  156. tigrbl/runtime/atoms/emit/paired_post.py +158 -0
  157. tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
  158. tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
  159. tigrbl/runtime/atoms/out/__init__.py +38 -0
  160. tigrbl/runtime/atoms/out/masking.py +135 -0
  161. tigrbl/runtime/atoms/refresh/__init__.py +38 -0
  162. tigrbl/runtime/atoms/refresh/demand.py +130 -0
  163. tigrbl/runtime/atoms/resolve/__init__.py +40 -0
  164. tigrbl/runtime/atoms/resolve/assemble.py +167 -0
  165. tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
  166. tigrbl/runtime/atoms/response/__init__.py +19 -0
  167. tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
  168. tigrbl/runtime/atoms/response/negotiate.py +30 -0
  169. tigrbl/runtime/atoms/response/negotiation.py +43 -0
  170. tigrbl/runtime/atoms/response/render.py +36 -0
  171. tigrbl/runtime/atoms/response/renderer.py +116 -0
  172. tigrbl/runtime/atoms/response/template.py +44 -0
  173. tigrbl/runtime/atoms/response/templates.py +88 -0
  174. tigrbl/runtime/atoms/schema/__init__.py +40 -0
  175. tigrbl/runtime/atoms/schema/collect_in.py +21 -0
  176. tigrbl/runtime/atoms/schema/collect_out.py +21 -0
  177. tigrbl/runtime/atoms/storage/__init__.py +38 -0
  178. tigrbl/runtime/atoms/storage/to_stored.py +167 -0
  179. tigrbl/runtime/atoms/wire/__init__.py +45 -0
  180. tigrbl/runtime/atoms/wire/build_in.py +166 -0
  181. tigrbl/runtime/atoms/wire/build_out.py +87 -0
  182. tigrbl/runtime/atoms/wire/dump.py +206 -0
  183. tigrbl/runtime/atoms/wire/validate_in.py +227 -0
  184. tigrbl/runtime/context.py +206 -0
  185. tigrbl/runtime/errors/__init__.py +61 -0
  186. tigrbl/runtime/errors/converters.py +214 -0
  187. tigrbl/runtime/errors/exceptions.py +124 -0
  188. tigrbl/runtime/errors/mappings.py +71 -0
  189. tigrbl/runtime/errors/utils.py +150 -0
  190. tigrbl/runtime/events.py +209 -0
  191. tigrbl/runtime/executor/__init__.py +6 -0
  192. tigrbl/runtime/executor/guards.py +132 -0
  193. tigrbl/runtime/executor/helpers.py +88 -0
  194. tigrbl/runtime/executor/invoke.py +150 -0
  195. tigrbl/runtime/executor/types.py +84 -0
  196. tigrbl/runtime/kernel.py +644 -0
  197. tigrbl/runtime/labels.py +353 -0
  198. tigrbl/runtime/opview.py +89 -0
  199. tigrbl/runtime/ordering.py +256 -0
  200. tigrbl/runtime/system.py +279 -0
  201. tigrbl/runtime/trace.py +330 -0
  202. tigrbl/schema/__init__.py +38 -0
  203. tigrbl/schema/_schema.py +27 -0
  204. tigrbl/schema/builder/__init__.py +17 -0
  205. tigrbl/schema/builder/build_schema.py +209 -0
  206. tigrbl/schema/builder/cache.py +24 -0
  207. tigrbl/schema/builder/compat.py +16 -0
  208. tigrbl/schema/builder/extras.py +85 -0
  209. tigrbl/schema/builder/helpers.py +51 -0
  210. tigrbl/schema/builder/list_params.py +117 -0
  211. tigrbl/schema/builder/strip_parent_fields.py +70 -0
  212. tigrbl/schema/collect.py +79 -0
  213. tigrbl/schema/decorators.py +68 -0
  214. tigrbl/schema/get_schema.py +86 -0
  215. tigrbl/schema/schema_spec.py +20 -0
  216. tigrbl/schema/shortcuts.py +42 -0
  217. tigrbl/schema/types.py +34 -0
  218. tigrbl/schema/utils.py +143 -0
  219. tigrbl/session/README.md +14 -0
  220. tigrbl/session/__init__.py +28 -0
  221. tigrbl/session/abc.py +76 -0
  222. tigrbl/session/base.py +151 -0
  223. tigrbl/session/decorators.py +43 -0
  224. tigrbl/session/default.py +118 -0
  225. tigrbl/session/shortcuts.py +50 -0
  226. tigrbl/session/spec.py +112 -0
  227. tigrbl/shortcuts.py +22 -0
  228. tigrbl/specs.py +44 -0
  229. tigrbl/system/__init__.py +13 -0
  230. tigrbl/system/diagnostics/__init__.py +24 -0
  231. tigrbl/system/diagnostics/compat.py +31 -0
  232. tigrbl/system/diagnostics/healthz.py +41 -0
  233. tigrbl/system/diagnostics/hookz.py +51 -0
  234. tigrbl/system/diagnostics/kernelz.py +20 -0
  235. tigrbl/system/diagnostics/methodz.py +43 -0
  236. tigrbl/system/diagnostics/router.py +73 -0
  237. tigrbl/system/diagnostics/utils.py +43 -0
  238. tigrbl/system/uvicorn.py +60 -0
  239. tigrbl/table/__init__.py +9 -0
  240. tigrbl/table/_base.py +260 -0
  241. tigrbl/table/_table.py +54 -0
  242. tigrbl/table/mro_collect.py +69 -0
  243. tigrbl/table/shortcuts.py +57 -0
  244. tigrbl/table/table_spec.py +28 -0
  245. tigrbl/transport/__init__.py +74 -0
  246. tigrbl/transport/jsonrpc/__init__.py +19 -0
  247. tigrbl/transport/jsonrpc/dispatcher.py +352 -0
  248. tigrbl/transport/jsonrpc/helpers.py +115 -0
  249. tigrbl/transport/jsonrpc/models.py +41 -0
  250. tigrbl/transport/rest/__init__.py +25 -0
  251. tigrbl/transport/rest/aggregator.py +132 -0
  252. tigrbl/types/__init__.py +170 -0
  253. tigrbl/types/allow_anon_provider.py +19 -0
  254. tigrbl/types/authn_abc.py +30 -0
  255. tigrbl/types/nested_path_provider.py +22 -0
  256. tigrbl/types/op.py +35 -0
  257. tigrbl/types/op_config_provider.py +17 -0
  258. tigrbl/types/op_verb_alias_provider.py +33 -0
  259. tigrbl/types/request_extras_provider.py +22 -0
  260. tigrbl/types/response_extras_provider.py +22 -0
  261. tigrbl/types/table_config_provider.py +13 -0
  262. tigrbl/types/uuid.py +55 -0
  263. tigrbl-0.3.0.dist-info/METADATA +516 -0
  264. tigrbl-0.3.0.dist-info/RECORD +266 -0
  265. {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dist-info}/WHEEL +1 -1
  266. tigrbl-0.3.0.dist-info/licenses/LICENSE +201 -0
  267. tigrbl/ExampleAgent.py +0 -1
  268. tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
  269. tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
@@ -0,0 +1,74 @@
1
+ # tigrbl/v3/transport/__init__.py
2
+ """
3
+ Tigrbl v3 – Transport package.
4
+
5
+ Routers & helpers for exposing your API over JSON-RPC and REST.
6
+
7
+ Quick usage:
8
+ from tigrbl.transport import (
9
+ build_jsonrpc_router, mount_jsonrpc,
10
+ build_rest_router, mount_rest,
11
+ )
12
+
13
+ # JSON-RPC
14
+ app.include_router(build_jsonrpc_router(api), prefix="/rpc")
15
+ # or supply a DB dependency from an Engine or Provider:
16
+ mount_jsonrpc(api, app, prefix="/rpc", get_db=my_engine.get_db)
17
+
18
+ # REST (aggregate all model routers under one prefix)
19
+ # after you include models with mount_router=False
20
+ app.include_router(build_rest_router(api, base_prefix="/api"))
21
+ # or:
22
+ mount_rest(api, app, base_prefix="/api")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Callable, Optional, Sequence
28
+
29
+ # JSON-RPC transport
30
+ from .jsonrpc import build_jsonrpc_router
31
+
32
+ # REST transport (aggregator over per-model routers)
33
+ from .rest import build_rest_router, mount_rest
34
+
35
+
36
+ def mount_jsonrpc(
37
+ api: Any,
38
+ app: Any,
39
+ *,
40
+ prefix: str = "/rpc",
41
+ get_db: Optional[Callable[..., Any]] = None,
42
+ tags: Sequence[str] | None = ("rpc",),
43
+ ):
44
+ """
45
+ Build a JSON-RPC router for `api` and include it on the given FastAPI `app`
46
+ (or any object exposing `include_router`).
47
+
48
+ Returns the created router so you can keep a reference if desired.
49
+
50
+ Parameters
51
+ ----------
52
+ tags:
53
+ Optional tags applied to the mounted "/rpc" endpoint. Defaults to
54
+ ``("rpc",)``.
55
+ """
56
+ router = build_jsonrpc_router(
57
+ api,
58
+ get_db=get_db,
59
+ tags=tags,
60
+ )
61
+ include_router = getattr(app, "include_router", None)
62
+ if callable(include_router):
63
+ include_router(router, prefix=prefix)
64
+ return router
65
+
66
+
67
+ __all__ = [
68
+ # JSON-RPC
69
+ "build_jsonrpc_router",
70
+ "mount_jsonrpc",
71
+ # REST
72
+ "build_rest_router",
73
+ "mount_rest",
74
+ ]
@@ -0,0 +1,19 @@
1
+ # tigrbl/v3/transport/jsonrpc/__init__.py
2
+ """
3
+ Tigrbl v3 – JSON-RPC transport.
4
+
5
+ Public helper:
6
+ - build_jsonrpc_router(
7
+ api, *, get_db=None, tags=("rpc",)
8
+ ) -> Router
9
+
10
+ Usage:
11
+ from tigrbl.transport.jsonrpc import build_jsonrpc_router
12
+ app.include_router(build_jsonrpc_router(api), prefix="/rpc")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .dispatcher import build_jsonrpc_router
18
+
19
+ __all__ = ["build_jsonrpc_router"]
@@ -0,0 +1,352 @@
1
+ # tigrbl/v3/transport/jsonrpc/dispatcher.py
2
+ """
3
+ JSON-RPC 2.0 dispatcher for Tigrbl v3.
4
+
5
+ This module exposes a single helper:
6
+
7
+ build_jsonrpc_router(api, *, get_db=None) -> Router
8
+
9
+ - It mounts a POST endpoint at "/" that accepts either a single JSON-RPC request
10
+ object or a batch (array) of request objects.
11
+ - Each JSON-RPC `method` must be of the form "Model.alias". The dispatcher will
12
+ look up `api.models["Model"]`, then call the bound coroutine at
13
+ `Model.rpc.<alias>(params, *, db, request, ctx)`.
14
+ - Input validation and output shaping are handled by the per-op RPC wrappers
15
+ built in `tigrbl.bindings.rpc`.
16
+ - Errors are converted to JSON-RPC error objects using the v3 runtime error
17
+ mappers (HTTP → RPC codes).
18
+
19
+ You would usually mount the returned router at `/rpc`, e.g.:
20
+
21
+ app.include_router(build_jsonrpc_router(api), prefix="/rpc")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import (
28
+ Any,
29
+ Callable,
30
+ Dict,
31
+ List,
32
+ Mapping,
33
+ Optional,
34
+ Sequence,
35
+ )
36
+
37
+ try:
38
+ from ...types import Router, Request, Body, Depends, HTTPException, Response
39
+ except Exception: # pragma: no cover
40
+ # Minimal shims to keep this importable without FastAPI (for typing/tools)
41
+ class Router: # type: ignore
42
+ def __init__(self, *a, **kw):
43
+ self.routes = []
44
+ self.dependencies = kw.get("dependencies", []) # for parity
45
+
46
+ def add_api_route(
47
+ self, path: str, endpoint: Callable, methods: Sequence[str], **opts
48
+ ):
49
+ self.routes.append((path, methods, endpoint, opts))
50
+
51
+ class Request: # type: ignore
52
+ def __init__(self, scope=None):
53
+ self.scope = scope or {}
54
+ self.state = type("S", (), {})()
55
+ self.query_params = {}
56
+
57
+ async def json(self) -> Any:
58
+ return {}
59
+
60
+ def Body(default=None, **kw): # type: ignore
61
+ return default
62
+
63
+ def Depends(fn): # type: ignore
64
+ return fn
65
+
66
+ class Response: # type: ignore
67
+ def __init__(self, status_code: int = 200, content: Any = None):
68
+ self.status_code = status_code
69
+ self.body = content
70
+
71
+ class HTTPException(Exception): # type: ignore
72
+ def __init__(self, status_code: int, detail: Any = None):
73
+ super().__init__(detail)
74
+ self.status_code = status_code
75
+ self.detail = detail
76
+
77
+
78
+ from ...runtime.errors import ERROR_MESSAGES, http_exc_to_rpc
79
+ from ...config.constants import TIGRBL_AUTH_CONTEXT_ATTR
80
+ from .models import RPCRequest, RPCResponse
81
+ from .helpers import (
82
+ _authorize,
83
+ _err,
84
+ _model_for,
85
+ _normalize_deps,
86
+ _normalize_params,
87
+ _ok,
88
+ _select_auth_dep,
89
+ _user_from_request,
90
+ )
91
+
92
+ logger = logging.getLogger(__name__)
93
+
94
+ Json = Mapping[str, Any]
95
+ Batch = Sequence[Mapping[str, Any]]
96
+
97
+
98
+ async def _dispatch_one(
99
+ *,
100
+ api: Any,
101
+ request: Request,
102
+ db: Any,
103
+ obj: Mapping[str, Any],
104
+ ) -> Optional[Dict[str, Any]]:
105
+ """
106
+ Handle a single JSON-RPC request object and return a response dict,
107
+ or None if it's a "notification" (no id field).
108
+ """
109
+ rid = obj.get("id", 1)
110
+ try:
111
+ # Basic JSON-RPC validation
112
+ if not isinstance(obj, Mapping):
113
+ return _err(-32600, "Invalid Request", rid) # not an object
114
+ # Be lenient: default to 2.0 when "jsonrpc" is omitted
115
+ if obj.get("jsonrpc", "2.0") != "2.0":
116
+ return _err(-32600, "Invalid Request", rid)
117
+ method = obj.get("method")
118
+ if not isinstance(method, str) or "." not in method:
119
+ return _err(-32601, "Method not found", rid)
120
+
121
+ model_name, alias = method.split(".", 1)
122
+ model = _model_for(api, model_name)
123
+ if model is None:
124
+ return _err(-32601, f"Unknown model '{model_name}'", rid)
125
+
126
+ # Locate RPC callable built by bindings.rpc
127
+ rpc_ns = getattr(model, "rpc", None)
128
+ rpc_call = getattr(rpc_ns, alias, None)
129
+ if rpc_call is None:
130
+ return _err(-32601, f"Method not found: {model_name}.{alias}", rid)
131
+
132
+ # Params
133
+ try:
134
+ params = _normalize_params(obj.get("params"))
135
+ except HTTPException as exc:
136
+ code, msg, data = http_exc_to_rpc(exc)
137
+ return _err(code, msg, rid, data)
138
+
139
+ # Enforce auth when required
140
+ if getattr(api, "_authn", None):
141
+ method_id = f"{model.__name__}.{alias}"
142
+ allow = getattr(api, "_allow_anon_ops", set())
143
+ user = _user_from_request(request)
144
+ if method_id not in allow and user is None:
145
+ raise HTTPException(status_code=401, detail="Unauthorized")
146
+
147
+ # Compose a context; allow middlewares to seed request.state.ctx
148
+ base_ctx: Dict[str, Any] = {}
149
+ extra_ctx = getattr(request.state, "ctx", None)
150
+ if isinstance(extra_ctx, Mapping):
151
+ base_ctx.update(extra_ctx)
152
+ base_ctx.setdefault("rpc_id", rid)
153
+ ac = getattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, None)
154
+ if ac is not None:
155
+ base_ctx["auth_context"] = ac
156
+
157
+ # Authorize (auth dep may already have raised; user may be on request.state)
158
+ _authorize(api, request, model, alias, params, _user_from_request(request))
159
+
160
+ # Execute
161
+ result = await rpc_call(params, db=db, request=request, ctx=base_ctx)
162
+
163
+ return _ok(result, rid)
164
+
165
+ except HTTPException as exc:
166
+ code, msg, data = http_exc_to_rpc(exc)
167
+ return _err(code, msg, rid, data)
168
+ except Exception:
169
+ logger.exception("jsonrpc dispatch failed")
170
+ # Internal error (per JSON-RPC); do not leak details
171
+ return _err(-32603, ERROR_MESSAGES.get(-32603, "Internal error"), rid)
172
+
173
+
174
+ # --------------------------------------------------------------------------- #
175
+ # Public router factory
176
+ # --------------------------------------------------------------------------- #
177
+
178
+
179
+ def build_jsonrpc_router(
180
+ api: Any,
181
+ *,
182
+ get_db: Optional[Callable[..., Any]] = None,
183
+ tags: Sequence[str] | None = ("rpc",),
184
+ ) -> Router:
185
+ """
186
+ Build and return a Router that serves a single POST endpoint at "/".
187
+ Mount it at your preferred prefix (e.g., "/rpc").
188
+
189
+ If `get_db` is provided, it will be used as a FastAPI
190
+ dependency for obtaining a DB session/connection. If not provided,
191
+ the dispatcher will try to use `request.state.db` (or pass `db=None`).
192
+
193
+ Security:
194
+ • If `api._authn` (or `api._optional_authn_dep`) is set, we inject it as a dependency
195
+ so it runs before dispatch. It may set `request.state.user` and/or raise 401.
196
+ • If `api._authorize` is set, we call it before executing the op; False/exception → 403.
197
+ • Additional router-level dependencies can be provided via `api.rpc_dependencies`.
198
+
199
+ The generated endpoint is tagged as "rpc" by default. Supply a custom
200
+ sequence via ``tags`` to override or set ``None`` to omit tags.
201
+ """
202
+ # Extra router-level deps (e.g., tracing, IP allowlist)
203
+ extra_router_deps = _normalize_deps(getattr(api, "rpc_dependencies", None))
204
+ router = Router(dependencies=extra_router_deps or None)
205
+
206
+ dep = get_db
207
+ auth_dep = _select_auth_dep(api)
208
+
209
+ if dep is not None and auth_dep is not None:
210
+ # Inject both DB and user via Depends
211
+ async def _endpoint(
212
+ request: Request,
213
+ body: RPCRequest | list[RPCRequest] = Body(...),
214
+ db: Any = Depends(dep),
215
+ user: Any = Depends(auth_dep),
216
+ ):
217
+ # set state for downstream handlers if dep returned user
218
+ try:
219
+ if user is not None and not hasattr(request.state, "user"):
220
+ setattr(request.state, "user", user)
221
+ except Exception:
222
+ pass
223
+
224
+ if isinstance(body, list):
225
+ responses: List[Dict[str, Any]] = []
226
+ for item in body:
227
+ resp = await _dispatch_one(
228
+ api=api, request=request, db=db, obj=item.model_dump()
229
+ )
230
+ if resp is not None:
231
+ responses.append(resp)
232
+ return responses
233
+ elif isinstance(body, RPCRequest):
234
+ resp = await _dispatch_one(
235
+ api=api, request=request, db=db, obj=body.model_dump()
236
+ )
237
+ if resp is None:
238
+ return Response(status_code=204)
239
+ return resp
240
+ else:
241
+ return _err(-32600, "Invalid Request", None)
242
+
243
+ elif dep is not None:
244
+ # Only DB dependency
245
+ async def _endpoint(
246
+ request: Request,
247
+ body: RPCRequest | list[RPCRequest] = Body(...),
248
+ db: Any = Depends(dep),
249
+ ):
250
+ if isinstance(body, list):
251
+ responses: List[Dict[str, Any]] = []
252
+ for item in body:
253
+ resp = await _dispatch_one(
254
+ api=api, request=request, db=db, obj=item.model_dump()
255
+ )
256
+ if resp is not None:
257
+ responses.append(resp)
258
+ return responses
259
+ elif isinstance(body, RPCRequest):
260
+ resp = await _dispatch_one(
261
+ api=api, request=request, db=db, obj=body.model_dump()
262
+ )
263
+ if resp is None:
264
+ return Response(status_code=204)
265
+ return resp
266
+ else:
267
+ return _err(-32600, "Invalid Request", None)
268
+
269
+ elif auth_dep is not None:
270
+ # Only auth dependency; DB will come from request.state.db
271
+ async def _endpoint(
272
+ request: Request,
273
+ body: RPCRequest | list[RPCRequest] = Body(...),
274
+ user: Any = Depends(auth_dep),
275
+ ):
276
+ try:
277
+ if user is not None and not hasattr(request.state, "user"):
278
+ setattr(request.state, "user", user)
279
+ except Exception:
280
+ pass
281
+
282
+ db = getattr(request.state, "db", None)
283
+ if isinstance(body, list):
284
+ responses: List[Dict[str, Any]] = []
285
+ for item in body:
286
+ resp = await _dispatch_one(
287
+ api=api, request=request, db=db, obj=item.model_dump()
288
+ )
289
+ if resp is not None:
290
+ responses.append(resp)
291
+ return responses
292
+ elif isinstance(body, RPCRequest):
293
+ resp = await _dispatch_one(
294
+ api=api, request=request, db=db, obj=body.model_dump()
295
+ )
296
+ if resp is None:
297
+ return Response(status_code=204)
298
+ return resp
299
+ else:
300
+ return _err(-32600, "Invalid Request", None)
301
+
302
+ else:
303
+ # No dependencies; attempt to read db (and user) from request.state
304
+ async def _endpoint(
305
+ request: Request, body: RPCRequest | list[RPCRequest] = Body(...)
306
+ ):
307
+ db = getattr(request.state, "db", None)
308
+ if isinstance(body, list):
309
+ responses: List[Dict[str, Any]] = []
310
+ for item in body:
311
+ resp = await _dispatch_one(
312
+ api=api, request=request, db=db, obj=item.model_dump()
313
+ )
314
+ if resp is not None:
315
+ responses.append(resp)
316
+ return responses
317
+ elif isinstance(body, RPCRequest):
318
+ resp = await _dispatch_one(
319
+ api=api, request=request, db=db, obj=body.model_dump()
320
+ )
321
+ if resp is None:
322
+ return Response(status_code=204)
323
+ return resp
324
+ else:
325
+ return _err(-32600, "Invalid Request", None)
326
+
327
+ # Attach routes for both "/rpc" and "/rpc/"
328
+ router.add_api_route(
329
+ path="",
330
+ endpoint=_endpoint,
331
+ methods=["POST"],
332
+ name="jsonrpc",
333
+ tags=list(tags) if tags else None,
334
+ summary="JSONRPC",
335
+ description="JSON-RPC 2.0 endpoint.",
336
+ response_model=RPCResponse | list[RPCResponse],
337
+ # extra router deps already applied via Router(dependencies=...)
338
+ )
339
+
340
+ # Compatibility: serve same endpoint without trailing slash
341
+ router.add_api_route(
342
+ path="/",
343
+ endpoint=_endpoint,
344
+ methods=["POST"],
345
+ name="jsonrpc_alt",
346
+ include_in_schema=False,
347
+ response_model=RPCResponse | list[RPCResponse],
348
+ )
349
+ return router
350
+
351
+
352
+ __all__ = ["build_jsonrpc_router"]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Mapping, Optional, Sequence
4
+
5
+ try:
6
+ from ...types import Depends, HTTPException
7
+ except Exception: # pragma: no cover
8
+
9
+ def Depends(fn): # type: ignore
10
+ return fn
11
+
12
+ class HTTPException(Exception): # type: ignore
13
+ def __init__(self, status_code: int, detail: Any = None):
14
+ super().__init__(detail)
15
+ self.status_code = status_code
16
+ self.detail = detail
17
+
18
+
19
+ def _ok(result: Any, id_: Any) -> Dict[str, Any]:
20
+ return {"jsonrpc": "2.0", "result": result, "id": id_}
21
+
22
+
23
+ def _err(code: int, msg: str, id_: Any, data: Any | None = None) -> Dict[str, Any]:
24
+ e: Dict[str, Any] = {
25
+ "jsonrpc": "2.0",
26
+ "error": {"code": code, "message": msg},
27
+ "id": id_,
28
+ }
29
+ if data is not None:
30
+ e["error"]["data"] = data
31
+ return e
32
+
33
+
34
+ def _normalize_params(params: Any) -> Any:
35
+ if params is None:
36
+ return {}
37
+ if isinstance(params, Mapping):
38
+ return dict(params)
39
+ if isinstance(params, Sequence) and not isinstance(params, (str, bytes)):
40
+ return list(params)
41
+ raise HTTPException(
42
+ status_code=400, detail="Invalid params: expected object or array"
43
+ )
44
+
45
+
46
+ def _model_for(api: Any, name: str) -> Optional[type]:
47
+ models: Dict[str, type] = getattr(api, "models", {}) or {}
48
+ mdl = models.get(name)
49
+ if mdl is not None:
50
+ return mdl
51
+ lower = name.lower()
52
+ for k, v in models.items():
53
+ if k.lower() == lower:
54
+ return v
55
+ return None
56
+
57
+
58
+ def _user_from_request(request: Any) -> Any | None:
59
+ return getattr(request.state, "user", None)
60
+
61
+
62
+ def _select_auth_dep(api: Any):
63
+ if getattr(api, "_optional_authn_dep", None):
64
+ return api._optional_authn_dep
65
+ if getattr(api, "_allow_anon", True) is False and getattr(api, "_authn", None):
66
+ return api._authn
67
+ if getattr(api, "_authn", None):
68
+ return api._authn
69
+ return None
70
+
71
+
72
+ def _normalize_deps(deps: Optional[Sequence[Any]]) -> list:
73
+ out = []
74
+ for d in deps or ():
75
+ try:
76
+ is_dep_obj = hasattr(d, "dependency")
77
+ except Exception:
78
+ is_dep_obj = False
79
+ out.append(d if is_dep_obj else Depends(d))
80
+ return out
81
+
82
+
83
+ def _authorize(
84
+ api: Any,
85
+ request: Any,
86
+ model: type,
87
+ alias: str,
88
+ payload: Mapping[str, Any],
89
+ user: Any | None,
90
+ ):
91
+ fn = getattr(api, "_authorize", None) or getattr(
92
+ model, "__tigrbl_authorize__", None
93
+ )
94
+ if not fn:
95
+ return
96
+ try:
97
+ rv = fn(request=request, model=model, alias=alias, payload=payload, user=user)
98
+ if rv is False:
99
+ raise HTTPException(status_code=403, detail="Forbidden")
100
+ except HTTPException:
101
+ raise
102
+ except Exception:
103
+ raise HTTPException(status_code=403, detail="Forbidden")
104
+
105
+
106
+ __all__ = [
107
+ "_ok",
108
+ "_err",
109
+ "_normalize_params",
110
+ "_model_for",
111
+ "_user_from_request",
112
+ "_select_auth_dep",
113
+ "_normalize_deps",
114
+ "_authorize",
115
+ ]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+ from uuid import UUID, uuid4
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ def _uuid_examples(schema: dict[str, Any]) -> None:
10
+ """Populate schema examples with a random UUID."""
11
+ schema["examples"] = [str(uuid4())]
12
+
13
+
14
+ class RPCRequest(BaseModel):
15
+ """JSON-RPC 2.0 request envelope."""
16
+
17
+ jsonrpc: Literal["2.0"] = "2.0"
18
+ method: str
19
+ params: dict[str, Any] | list[Any] = Field(default_factory=dict)
20
+ id: UUID | str | int | None = Field(
21
+ default_factory=uuid4,
22
+ json_schema_extra=_uuid_examples,
23
+ )
24
+
25
+
26
+ class RPCError(BaseModel):
27
+ code: int
28
+ message: str
29
+ data: Any | None = None
30
+
31
+
32
+ class RPCResponse(BaseModel):
33
+ """JSON-RPC 2.0 response envelope."""
34
+
35
+ jsonrpc: Literal["2.0"] = "2.0"
36
+ result: Any | None = None
37
+ error: RPCError | None = None
38
+ id: UUID | str | int | None = Field(
39
+ default=None,
40
+ json_schema_extra=_uuid_examples,
41
+ )
@@ -0,0 +1,25 @@
1
+ # tigrbl/v3/transport/rest/__init__.py
2
+ """
3
+ Tigrbl v3 – REST transport wrapper.
4
+
5
+ Use this when you prefer to mount a single top-level router that aggregates all
6
+ model routers (instead of mounting each one inside include_model).
7
+
8
+ Typical usage:
9
+ from tigrbl.transport.rest import build_rest_router, mount_rest
10
+
11
+ # When including models, skip mounting per-model:
12
+ api.include_model(User, mount_router=False)
13
+ api.include_model(Team, mount_router=False)
14
+
15
+ # Then aggregate & mount once:
16
+ app.include_router(build_rest_router(api, base_prefix="/api"))
17
+ # or:
18
+ mount_rest(api, app, base_prefix="/api")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .aggregator import build_rest_router, mount_rest
24
+
25
+ __all__ = ["build_rest_router", "mount_rest"]