tigrbl 0.0.1.dev1__py3-none-any.whl → 0.3.0.dev3__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 (252) 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 +72 -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 +286 -0
  9. tigrbl/app/__init__.py +0 -0
  10. tigrbl/app/_app.py +61 -0
  11. tigrbl/app/app_spec.py +42 -0
  12. tigrbl/app/mro_collect.py +67 -0
  13. tigrbl/app/shortcuts.py +65 -0
  14. tigrbl/app/tigrbl_app.py +314 -0
  15. tigrbl/bindings/__init__.py +73 -0
  16. tigrbl/bindings/api/__init__.py +12 -0
  17. tigrbl/bindings/api/common.py +109 -0
  18. tigrbl/bindings/api/include.py +256 -0
  19. tigrbl/bindings/api/resource_proxy.py +149 -0
  20. tigrbl/bindings/api/rpc.py +111 -0
  21. tigrbl/bindings/columns.py +49 -0
  22. tigrbl/bindings/handlers/__init__.py +11 -0
  23. tigrbl/bindings/handlers/builder.py +119 -0
  24. tigrbl/bindings/handlers/ctx.py +74 -0
  25. tigrbl/bindings/handlers/identifiers.py +228 -0
  26. tigrbl/bindings/handlers/namespaces.py +51 -0
  27. tigrbl/bindings/handlers/steps.py +276 -0
  28. tigrbl/bindings/hooks.py +311 -0
  29. tigrbl/bindings/model.py +194 -0
  30. tigrbl/bindings/model_helpers.py +139 -0
  31. tigrbl/bindings/model_registry.py +77 -0
  32. tigrbl/bindings/rest/__init__.py +7 -0
  33. tigrbl/bindings/rest/attach.py +34 -0
  34. tigrbl/bindings/rest/collection.py +265 -0
  35. tigrbl/bindings/rest/common.py +116 -0
  36. tigrbl/bindings/rest/fastapi.py +76 -0
  37. tigrbl/bindings/rest/helpers.py +119 -0
  38. tigrbl/bindings/rest/io.py +317 -0
  39. tigrbl/bindings/rest/member.py +367 -0
  40. tigrbl/bindings/rest/router.py +292 -0
  41. tigrbl/bindings/rest/routing.py +133 -0
  42. tigrbl/bindings/rpc.py +364 -0
  43. tigrbl/bindings/schemas/__init__.py +11 -0
  44. tigrbl/bindings/schemas/builder.py +348 -0
  45. tigrbl/bindings/schemas/defaults.py +260 -0
  46. tigrbl/bindings/schemas/utils.py +193 -0
  47. tigrbl/column/README.md +62 -0
  48. tigrbl/column/__init__.py +72 -0
  49. tigrbl/column/_column.py +96 -0
  50. tigrbl/column/column_spec.py +40 -0
  51. tigrbl/column/field_spec.py +31 -0
  52. tigrbl/column/infer/__init__.py +25 -0
  53. tigrbl/column/infer/core.py +92 -0
  54. tigrbl/column/infer/jsonhints.py +44 -0
  55. tigrbl/column/infer/planning.py +133 -0
  56. tigrbl/column/infer/types.py +102 -0
  57. tigrbl/column/infer/utils.py +59 -0
  58. tigrbl/column/io_spec.py +133 -0
  59. tigrbl/column/mro_collect.py +59 -0
  60. tigrbl/column/shortcuts.py +89 -0
  61. tigrbl/column/storage_spec.py +65 -0
  62. tigrbl/config/__init__.py +19 -0
  63. tigrbl/config/constants.py +224 -0
  64. tigrbl/config/defaults.py +29 -0
  65. tigrbl/config/resolver.py +295 -0
  66. tigrbl/core/__init__.py +47 -0
  67. tigrbl/core/crud/__init__.py +36 -0
  68. tigrbl/core/crud/bulk.py +168 -0
  69. tigrbl/core/crud/helpers/__init__.py +76 -0
  70. tigrbl/core/crud/helpers/db.py +92 -0
  71. tigrbl/core/crud/helpers/enum.py +86 -0
  72. tigrbl/core/crud/helpers/filters.py +162 -0
  73. tigrbl/core/crud/helpers/model.py +123 -0
  74. tigrbl/core/crud/helpers/normalize.py +99 -0
  75. tigrbl/core/crud/ops.py +235 -0
  76. tigrbl/ddl/__init__.py +344 -0
  77. tigrbl/decorators.py +17 -0
  78. tigrbl/deps/__init__.py +20 -0
  79. tigrbl/deps/fastapi.py +45 -0
  80. tigrbl/deps/favicon.svg +4 -0
  81. tigrbl/deps/jinja.py +27 -0
  82. tigrbl/deps/pydantic.py +10 -0
  83. tigrbl/deps/sqlalchemy.py +94 -0
  84. tigrbl/deps/starlette.py +36 -0
  85. tigrbl/engine/__init__.py +26 -0
  86. tigrbl/engine/_engine.py +130 -0
  87. tigrbl/engine/bind.py +33 -0
  88. tigrbl/engine/builders.py +236 -0
  89. tigrbl/engine/collect.py +111 -0
  90. tigrbl/engine/decorators.py +108 -0
  91. tigrbl/engine/engine_spec.py +261 -0
  92. tigrbl/engine/resolver.py +224 -0
  93. tigrbl/engine/shortcuts.py +216 -0
  94. tigrbl/hook/__init__.py +21 -0
  95. tigrbl/hook/_hook.py +22 -0
  96. tigrbl/hook/decorators.py +28 -0
  97. tigrbl/hook/hook_spec.py +24 -0
  98. tigrbl/hook/mro_collect.py +98 -0
  99. tigrbl/hook/shortcuts.py +44 -0
  100. tigrbl/hook/types.py +76 -0
  101. tigrbl/op/__init__.py +50 -0
  102. tigrbl/op/_op.py +31 -0
  103. tigrbl/op/canonical.py +31 -0
  104. tigrbl/op/collect.py +11 -0
  105. tigrbl/op/decorators.py +238 -0
  106. tigrbl/op/model_registry.py +301 -0
  107. tigrbl/op/mro_collect.py +99 -0
  108. tigrbl/op/resolver.py +216 -0
  109. tigrbl/op/types.py +136 -0
  110. tigrbl/orm/__init__.py +1 -0
  111. tigrbl/orm/mixins/_RowBound.py +83 -0
  112. tigrbl/orm/mixins/__init__.py +95 -0
  113. tigrbl/orm/mixins/bootstrappable.py +113 -0
  114. tigrbl/orm/mixins/bound.py +47 -0
  115. tigrbl/orm/mixins/edges.py +40 -0
  116. tigrbl/orm/mixins/fields.py +165 -0
  117. tigrbl/orm/mixins/hierarchy.py +54 -0
  118. tigrbl/orm/mixins/key_digest.py +44 -0
  119. tigrbl/orm/mixins/lifecycle.py +115 -0
  120. tigrbl/orm/mixins/locks.py +51 -0
  121. tigrbl/orm/mixins/markers.py +16 -0
  122. tigrbl/orm/mixins/operations.py +57 -0
  123. tigrbl/orm/mixins/ownable.py +337 -0
  124. tigrbl/orm/mixins/principals.py +98 -0
  125. tigrbl/orm/mixins/tenant_bound.py +301 -0
  126. tigrbl/orm/mixins/upsertable.py +111 -0
  127. tigrbl/orm/mixins/utils.py +49 -0
  128. tigrbl/orm/tables/__init__.py +72 -0
  129. tigrbl/orm/tables/_base.py +8 -0
  130. tigrbl/orm/tables/audit.py +56 -0
  131. tigrbl/orm/tables/client.py +25 -0
  132. tigrbl/orm/tables/group.py +29 -0
  133. tigrbl/orm/tables/org.py +30 -0
  134. tigrbl/orm/tables/rbac.py +76 -0
  135. tigrbl/orm/tables/status.py +106 -0
  136. tigrbl/orm/tables/tenant.py +22 -0
  137. tigrbl/orm/tables/user.py +39 -0
  138. tigrbl/response/README.md +34 -0
  139. tigrbl/response/__init__.py +33 -0
  140. tigrbl/response/bind.py +12 -0
  141. tigrbl/response/decorators.py +37 -0
  142. tigrbl/response/resolver.py +83 -0
  143. tigrbl/response/shortcuts.py +144 -0
  144. tigrbl/response/types.py +49 -0
  145. tigrbl/rest/__init__.py +27 -0
  146. tigrbl/runtime/README.md +129 -0
  147. tigrbl/runtime/__init__.py +20 -0
  148. tigrbl/runtime/atoms/__init__.py +102 -0
  149. tigrbl/runtime/atoms/emit/__init__.py +42 -0
  150. tigrbl/runtime/atoms/emit/paired_post.py +158 -0
  151. tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
  152. tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
  153. tigrbl/runtime/atoms/out/__init__.py +38 -0
  154. tigrbl/runtime/atoms/out/masking.py +135 -0
  155. tigrbl/runtime/atoms/refresh/__init__.py +38 -0
  156. tigrbl/runtime/atoms/refresh/demand.py +130 -0
  157. tigrbl/runtime/atoms/resolve/__init__.py +40 -0
  158. tigrbl/runtime/atoms/resolve/assemble.py +167 -0
  159. tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
  160. tigrbl/runtime/atoms/response/__init__.py +17 -0
  161. tigrbl/runtime/atoms/response/negotiate.py +30 -0
  162. tigrbl/runtime/atoms/response/negotiation.py +43 -0
  163. tigrbl/runtime/atoms/response/render.py +36 -0
  164. tigrbl/runtime/atoms/response/renderer.py +116 -0
  165. tigrbl/runtime/atoms/response/template.py +44 -0
  166. tigrbl/runtime/atoms/response/templates.py +88 -0
  167. tigrbl/runtime/atoms/schema/__init__.py +40 -0
  168. tigrbl/runtime/atoms/schema/collect_in.py +21 -0
  169. tigrbl/runtime/atoms/schema/collect_out.py +21 -0
  170. tigrbl/runtime/atoms/storage/__init__.py +38 -0
  171. tigrbl/runtime/atoms/storage/to_stored.py +167 -0
  172. tigrbl/runtime/atoms/wire/__init__.py +45 -0
  173. tigrbl/runtime/atoms/wire/build_in.py +166 -0
  174. tigrbl/runtime/atoms/wire/build_out.py +87 -0
  175. tigrbl/runtime/atoms/wire/dump.py +206 -0
  176. tigrbl/runtime/atoms/wire/validate_in.py +227 -0
  177. tigrbl/runtime/context.py +206 -0
  178. tigrbl/runtime/errors/__init__.py +61 -0
  179. tigrbl/runtime/errors/converters.py +214 -0
  180. tigrbl/runtime/errors/exceptions.py +124 -0
  181. tigrbl/runtime/errors/mappings.py +71 -0
  182. tigrbl/runtime/errors/utils.py +150 -0
  183. tigrbl/runtime/events.py +209 -0
  184. tigrbl/runtime/executor/__init__.py +6 -0
  185. tigrbl/runtime/executor/guards.py +132 -0
  186. tigrbl/runtime/executor/helpers.py +88 -0
  187. tigrbl/runtime/executor/invoke.py +150 -0
  188. tigrbl/runtime/executor/types.py +84 -0
  189. tigrbl/runtime/kernel.py +628 -0
  190. tigrbl/runtime/labels.py +353 -0
  191. tigrbl/runtime/opview.py +87 -0
  192. tigrbl/runtime/ordering.py +256 -0
  193. tigrbl/runtime/system.py +279 -0
  194. tigrbl/runtime/trace.py +330 -0
  195. tigrbl/schema/__init__.py +38 -0
  196. tigrbl/schema/_schema.py +27 -0
  197. tigrbl/schema/builder/__init__.py +17 -0
  198. tigrbl/schema/builder/build_schema.py +209 -0
  199. tigrbl/schema/builder/cache.py +24 -0
  200. tigrbl/schema/builder/compat.py +16 -0
  201. tigrbl/schema/builder/extras.py +85 -0
  202. tigrbl/schema/builder/helpers.py +51 -0
  203. tigrbl/schema/builder/list_params.py +117 -0
  204. tigrbl/schema/builder/strip_parent_fields.py +70 -0
  205. tigrbl/schema/collect.py +55 -0
  206. tigrbl/schema/decorators.py +68 -0
  207. tigrbl/schema/get_schema.py +86 -0
  208. tigrbl/schema/schema_spec.py +20 -0
  209. tigrbl/schema/shortcuts.py +42 -0
  210. tigrbl/schema/types.py +34 -0
  211. tigrbl/schema/utils.py +143 -0
  212. tigrbl/shortcuts.py +22 -0
  213. tigrbl/specs.py +44 -0
  214. tigrbl/system/__init__.py +12 -0
  215. tigrbl/system/diagnostics/__init__.py +24 -0
  216. tigrbl/system/diagnostics/compat.py +31 -0
  217. tigrbl/system/diagnostics/healthz.py +41 -0
  218. tigrbl/system/diagnostics/hookz.py +51 -0
  219. tigrbl/system/diagnostics/kernelz.py +20 -0
  220. tigrbl/system/diagnostics/methodz.py +43 -0
  221. tigrbl/system/diagnostics/router.py +73 -0
  222. tigrbl/system/diagnostics/utils.py +43 -0
  223. tigrbl/table/__init__.py +9 -0
  224. tigrbl/table/_base.py +237 -0
  225. tigrbl/table/_table.py +54 -0
  226. tigrbl/table/mro_collect.py +69 -0
  227. tigrbl/table/shortcuts.py +57 -0
  228. tigrbl/table/table_spec.py +28 -0
  229. tigrbl/transport/__init__.py +74 -0
  230. tigrbl/transport/jsonrpc/__init__.py +19 -0
  231. tigrbl/transport/jsonrpc/dispatcher.py +352 -0
  232. tigrbl/transport/jsonrpc/helpers.py +115 -0
  233. tigrbl/transport/jsonrpc/models.py +41 -0
  234. tigrbl/transport/rest/__init__.py +25 -0
  235. tigrbl/transport/rest/aggregator.py +132 -0
  236. tigrbl/types/__init__.py +174 -0
  237. tigrbl/types/allow_anon_provider.py +19 -0
  238. tigrbl/types/authn_abc.py +30 -0
  239. tigrbl/types/nested_path_provider.py +22 -0
  240. tigrbl/types/op.py +35 -0
  241. tigrbl/types/op_config_provider.py +17 -0
  242. tigrbl/types/op_verb_alias_provider.py +33 -0
  243. tigrbl/types/request_extras_provider.py +22 -0
  244. tigrbl/types/response_extras_provider.py +22 -0
  245. tigrbl/types/table_config_provider.py +13 -0
  246. tigrbl-0.3.0.dev3.dist-info/LICENSE +201 -0
  247. tigrbl-0.3.0.dev3.dist-info/METADATA +501 -0
  248. tigrbl-0.3.0.dev3.dist-info/RECORD +249 -0
  249. tigrbl/ExampleAgent.py +0 -1
  250. tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
  251. tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
  252. {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dev3.dist-info}/WHEEL +0 -0
@@ -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"]
@@ -0,0 +1,132 @@
1
+ # tigrbl/v3/transport/rest/aggregator.py
2
+ """
3
+ Aggregates per-model REST routers into a single Router.
4
+
5
+ This does not build endpoints by itself — it simply collects the routers that
6
+ `tigrbl.bindings.rest` attached to each model at `model.rest.router`.
7
+
8
+ Recommended workflow:
9
+ 1) Include models with `mount_router=False` so you don't double-mount:
10
+ api.include_model(User, mount_router=False)
11
+ api.include_model(Team, mount_router=False)
12
+ 2) Aggregate and mount once:
13
+ app.include_router(build_rest_router(api, base_prefix="/api"))
14
+ or:
15
+ mount_rest(api, app, base_prefix="/api")
16
+
17
+ Notes:
18
+ • Router paths already include `/{resource}`; we only add `base_prefix`.
19
+ • Model-level auth/db deps and extra REST deps are already attached to each
20
+ model router by `bindings.rest`; this wrapper can add *additional* top-level
21
+ dependencies if you pass them in.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Mapping, Optional, Sequence
27
+
28
+ try:
29
+ from ...types import Router, Depends
30
+ except Exception: # pragma: no cover
31
+ # Minimal shim to keep importable without FastAPI
32
+ class Router: # type: ignore
33
+ def __init__(self, *a, dependencies: Optional[Sequence[Any]] = None, **kw):
34
+ self.routes = []
35
+ self.includes = []
36
+ self.dependencies = list(dependencies or [])
37
+
38
+ def add_api_route(self, path: str, endpoint, methods: Sequence[str], **opts):
39
+ self.routes.append((path, methods, endpoint, opts))
40
+
41
+ def include_router(self, router: "Router", *, prefix: str = "", **opts):
42
+ self.includes.append((router, prefix, opts))
43
+
44
+ def Depends(fn): # type: ignore
45
+ return fn
46
+
47
+
48
+ def _norm_prefix(p: Optional[str]) -> str:
49
+ if not p:
50
+ return ""
51
+ if not p.startswith("/"):
52
+ p = "/" + p
53
+ # Avoid double trailing slashes; FastAPI is lenient but keep it clean
54
+ return p.rstrip("/")
55
+
56
+
57
+ def _normalize_deps(deps: Optional[Sequence[Any]]) -> list:
58
+ """Accept either Depends(...) objects or plain callables."""
59
+ out = []
60
+ for d in deps or ():
61
+ try:
62
+ is_dep_obj = hasattr(d, "dependency")
63
+ except Exception:
64
+ is_dep_obj = False
65
+ out.append(d if is_dep_obj else Depends(d))
66
+ return out
67
+
68
+
69
+ def _iter_models(api: Any, only: Optional[Sequence[type]] = None) -> Sequence[type]:
70
+ if only:
71
+ return list(only)
72
+ models: Mapping[str, type] = getattr(api, "models", {}) or {}
73
+ # deterministic iteration
74
+ return [models[k] for k in sorted(models.keys())]
75
+
76
+
77
+ def build_rest_router(
78
+ api: Any,
79
+ *,
80
+ models: Optional[Sequence[type]] = None,
81
+ base_prefix: str = "",
82
+ dependencies: Optional[Sequence[Any]] = None,
83
+ ) -> Router:
84
+ """
85
+ Build a top-level Router that includes each model's router under `base_prefix`.
86
+
87
+ Args:
88
+ api: your Tigrbl facade (or any object with `.models` dict).
89
+ models: optional subset of models to include; defaults to all bound models.
90
+ base_prefix: prefix applied once for all included routers (e.g., "/api").
91
+ dependencies: additional router-level dependencies (Depends(...) or callables).
92
+
93
+ Returns:
94
+ Router ready to be mounted on your FastAPI app.
95
+ """
96
+ root = Router(dependencies=_normalize_deps(dependencies))
97
+ prefix = _norm_prefix(base_prefix)
98
+
99
+ for model in _iter_models(api, models):
100
+ rest_ns = getattr(model, "rest", None)
101
+ router = getattr(rest_ns, "router", None) if rest_ns is not None else None
102
+ if router is None:
103
+ # Nothing to include for this model (not bound or no routes)
104
+ continue
105
+ # Include with only the base prefix; the model router already has /{resource} in its paths
106
+ root.include_router(router, prefix=prefix or "")
107
+ return root
108
+
109
+
110
+ def mount_rest(
111
+ api: Any,
112
+ app: Any,
113
+ *,
114
+ models: Optional[Sequence[type]] = None,
115
+ base_prefix: str = "",
116
+ dependencies: Optional[Sequence[Any]] = None,
117
+ ) -> Router:
118
+ """
119
+ Convenience helper: build the aggregated router and include it on `app`.
120
+
121
+ Returns the created router so you can keep a reference if desired.
122
+ """
123
+ router = build_rest_router(
124
+ api, models=models, base_prefix=base_prefix, dependencies=dependencies
125
+ )
126
+ include = getattr(app, "include_router", None)
127
+ if callable(include):
128
+ include(router)
129
+ return router
130
+
131
+
132
+ __all__ = ["build_rest_router", "mount_rest"]