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.
- tigrbl/README.md +94 -0
- tigrbl/__init__.py +139 -14
- tigrbl/api/__init__.py +6 -0
- tigrbl/api/_api.py +97 -0
- tigrbl/api/api_spec.py +30 -0
- tigrbl/api/mro_collect.py +43 -0
- tigrbl/api/shortcuts.py +56 -0
- tigrbl/api/tigrbl_api.py +291 -0
- tigrbl/app/__init__.py +0 -0
- tigrbl/app/_app.py +86 -0
- tigrbl/app/_model_registry.py +41 -0
- tigrbl/app/app_spec.py +42 -0
- tigrbl/app/mro_collect.py +67 -0
- tigrbl/app/shortcuts.py +65 -0
- tigrbl/app/tigrbl_app.py +319 -0
- tigrbl/bindings/__init__.py +73 -0
- tigrbl/bindings/api/__init__.py +12 -0
- tigrbl/bindings/api/common.py +109 -0
- tigrbl/bindings/api/include.py +256 -0
- tigrbl/bindings/api/resource_proxy.py +149 -0
- tigrbl/bindings/api/rpc.py +111 -0
- tigrbl/bindings/columns.py +49 -0
- tigrbl/bindings/handlers/__init__.py +11 -0
- tigrbl/bindings/handlers/builder.py +119 -0
- tigrbl/bindings/handlers/ctx.py +74 -0
- tigrbl/bindings/handlers/identifiers.py +228 -0
- tigrbl/bindings/handlers/namespaces.py +51 -0
- tigrbl/bindings/handlers/steps.py +276 -0
- tigrbl/bindings/hooks.py +311 -0
- tigrbl/bindings/model.py +194 -0
- tigrbl/bindings/model_helpers.py +139 -0
- tigrbl/bindings/model_registry.py +77 -0
- tigrbl/bindings/rest/__init__.py +7 -0
- tigrbl/bindings/rest/attach.py +34 -0
- tigrbl/bindings/rest/collection.py +286 -0
- tigrbl/bindings/rest/common.py +120 -0
- tigrbl/bindings/rest/fastapi.py +76 -0
- tigrbl/bindings/rest/helpers.py +119 -0
- tigrbl/bindings/rest/io.py +317 -0
- tigrbl/bindings/rest/io_headers.py +49 -0
- tigrbl/bindings/rest/member.py +386 -0
- tigrbl/bindings/rest/router.py +296 -0
- tigrbl/bindings/rest/routing.py +153 -0
- tigrbl/bindings/rpc.py +364 -0
- tigrbl/bindings/schemas/__init__.py +11 -0
- tigrbl/bindings/schemas/builder.py +348 -0
- tigrbl/bindings/schemas/defaults.py +260 -0
- tigrbl/bindings/schemas/utils.py +193 -0
- tigrbl/column/README.md +62 -0
- tigrbl/column/__init__.py +72 -0
- tigrbl/column/_column.py +96 -0
- tigrbl/column/column_spec.py +40 -0
- tigrbl/column/field_spec.py +31 -0
- tigrbl/column/infer/__init__.py +25 -0
- tigrbl/column/infer/core.py +92 -0
- tigrbl/column/infer/jsonhints.py +44 -0
- tigrbl/column/infer/planning.py +133 -0
- tigrbl/column/infer/types.py +102 -0
- tigrbl/column/infer/utils.py +59 -0
- tigrbl/column/io_spec.py +136 -0
- tigrbl/column/mro_collect.py +59 -0
- tigrbl/column/shortcuts.py +89 -0
- tigrbl/column/storage_spec.py +65 -0
- tigrbl/config/__init__.py +19 -0
- tigrbl/config/constants.py +224 -0
- tigrbl/config/defaults.py +29 -0
- tigrbl/config/resolver.py +295 -0
- tigrbl/core/__init__.py +47 -0
- tigrbl/core/crud/__init__.py +36 -0
- tigrbl/core/crud/bulk.py +168 -0
- tigrbl/core/crud/helpers/__init__.py +76 -0
- tigrbl/core/crud/helpers/db.py +92 -0
- tigrbl/core/crud/helpers/enum.py +86 -0
- tigrbl/core/crud/helpers/filters.py +162 -0
- tigrbl/core/crud/helpers/model.py +123 -0
- tigrbl/core/crud/helpers/normalize.py +99 -0
- tigrbl/core/crud/ops.py +235 -0
- tigrbl/ddl/__init__.py +344 -0
- tigrbl/decorators.py +17 -0
- tigrbl/deps/__init__.py +20 -0
- tigrbl/deps/fastapi.py +45 -0
- tigrbl/deps/favicon.svg +4 -0
- tigrbl/deps/jinja.py +27 -0
- tigrbl/deps/pydantic.py +10 -0
- tigrbl/deps/sqlalchemy.py +94 -0
- tigrbl/deps/starlette.py +36 -0
- tigrbl/engine/__init__.py +45 -0
- tigrbl/engine/_engine.py +144 -0
- tigrbl/engine/bind.py +33 -0
- tigrbl/engine/builders.py +236 -0
- tigrbl/engine/capabilities.py +29 -0
- tigrbl/engine/collect.py +111 -0
- tigrbl/engine/decorators.py +110 -0
- tigrbl/engine/docs/PLUGINS.md +49 -0
- tigrbl/engine/engine_spec.py +355 -0
- tigrbl/engine/plugins.py +52 -0
- tigrbl/engine/registry.py +36 -0
- tigrbl/engine/resolver.py +224 -0
- tigrbl/engine/shortcuts.py +216 -0
- tigrbl/hook/__init__.py +21 -0
- tigrbl/hook/_hook.py +22 -0
- tigrbl/hook/decorators.py +28 -0
- tigrbl/hook/hook_spec.py +24 -0
- tigrbl/hook/mro_collect.py +98 -0
- tigrbl/hook/shortcuts.py +44 -0
- tigrbl/hook/types.py +76 -0
- tigrbl/op/__init__.py +50 -0
- tigrbl/op/_op.py +31 -0
- tigrbl/op/canonical.py +31 -0
- tigrbl/op/collect.py +11 -0
- tigrbl/op/decorators.py +238 -0
- tigrbl/op/model_registry.py +301 -0
- tigrbl/op/mro_collect.py +99 -0
- tigrbl/op/resolver.py +216 -0
- tigrbl/op/types.py +136 -0
- tigrbl/orm/__init__.py +1 -0
- tigrbl/orm/mixins/_RowBound.py +83 -0
- tigrbl/orm/mixins/__init__.py +95 -0
- tigrbl/orm/mixins/bootstrappable.py +113 -0
- tigrbl/orm/mixins/bound.py +47 -0
- tigrbl/orm/mixins/edges.py +40 -0
- tigrbl/orm/mixins/fields.py +165 -0
- tigrbl/orm/mixins/hierarchy.py +54 -0
- tigrbl/orm/mixins/key_digest.py +44 -0
- tigrbl/orm/mixins/lifecycle.py +115 -0
- tigrbl/orm/mixins/locks.py +51 -0
- tigrbl/orm/mixins/markers.py +16 -0
- tigrbl/orm/mixins/operations.py +57 -0
- tigrbl/orm/mixins/ownable.py +337 -0
- tigrbl/orm/mixins/principals.py +98 -0
- tigrbl/orm/mixins/tenant_bound.py +301 -0
- tigrbl/orm/mixins/upsertable.py +118 -0
- tigrbl/orm/mixins/utils.py +49 -0
- tigrbl/orm/tables/__init__.py +72 -0
- tigrbl/orm/tables/_base.py +8 -0
- tigrbl/orm/tables/audit.py +56 -0
- tigrbl/orm/tables/client.py +25 -0
- tigrbl/orm/tables/group.py +29 -0
- tigrbl/orm/tables/org.py +30 -0
- tigrbl/orm/tables/rbac.py +76 -0
- tigrbl/orm/tables/status.py +106 -0
- tigrbl/orm/tables/tenant.py +22 -0
- tigrbl/orm/tables/user.py +39 -0
- tigrbl/response/README.md +34 -0
- tigrbl/response/__init__.py +33 -0
- tigrbl/response/bind.py +12 -0
- tigrbl/response/decorators.py +37 -0
- tigrbl/response/resolver.py +83 -0
- tigrbl/response/shortcuts.py +171 -0
- tigrbl/response/types.py +49 -0
- tigrbl/rest/__init__.py +27 -0
- tigrbl/runtime/README.md +129 -0
- tigrbl/runtime/__init__.py +20 -0
- tigrbl/runtime/atoms/__init__.py +102 -0
- tigrbl/runtime/atoms/emit/__init__.py +42 -0
- tigrbl/runtime/atoms/emit/paired_post.py +158 -0
- tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
- tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
- tigrbl/runtime/atoms/out/__init__.py +38 -0
- tigrbl/runtime/atoms/out/masking.py +135 -0
- tigrbl/runtime/atoms/refresh/__init__.py +38 -0
- tigrbl/runtime/atoms/refresh/demand.py +130 -0
- tigrbl/runtime/atoms/resolve/__init__.py +40 -0
- tigrbl/runtime/atoms/resolve/assemble.py +167 -0
- tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
- tigrbl/runtime/atoms/response/__init__.py +19 -0
- tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
- tigrbl/runtime/atoms/response/negotiate.py +30 -0
- tigrbl/runtime/atoms/response/negotiation.py +43 -0
- tigrbl/runtime/atoms/response/render.py +36 -0
- tigrbl/runtime/atoms/response/renderer.py +116 -0
- tigrbl/runtime/atoms/response/template.py +44 -0
- tigrbl/runtime/atoms/response/templates.py +88 -0
- tigrbl/runtime/atoms/schema/__init__.py +40 -0
- tigrbl/runtime/atoms/schema/collect_in.py +21 -0
- tigrbl/runtime/atoms/schema/collect_out.py +21 -0
- tigrbl/runtime/atoms/storage/__init__.py +38 -0
- tigrbl/runtime/atoms/storage/to_stored.py +167 -0
- tigrbl/runtime/atoms/wire/__init__.py +45 -0
- tigrbl/runtime/atoms/wire/build_in.py +166 -0
- tigrbl/runtime/atoms/wire/build_out.py +87 -0
- tigrbl/runtime/atoms/wire/dump.py +206 -0
- tigrbl/runtime/atoms/wire/validate_in.py +227 -0
- tigrbl/runtime/context.py +206 -0
- tigrbl/runtime/errors/__init__.py +61 -0
- tigrbl/runtime/errors/converters.py +214 -0
- tigrbl/runtime/errors/exceptions.py +124 -0
- tigrbl/runtime/errors/mappings.py +71 -0
- tigrbl/runtime/errors/utils.py +150 -0
- tigrbl/runtime/events.py +209 -0
- tigrbl/runtime/executor/__init__.py +6 -0
- tigrbl/runtime/executor/guards.py +132 -0
- tigrbl/runtime/executor/helpers.py +88 -0
- tigrbl/runtime/executor/invoke.py +150 -0
- tigrbl/runtime/executor/types.py +84 -0
- tigrbl/runtime/kernel.py +644 -0
- tigrbl/runtime/labels.py +353 -0
- tigrbl/runtime/opview.py +89 -0
- tigrbl/runtime/ordering.py +256 -0
- tigrbl/runtime/system.py +279 -0
- tigrbl/runtime/trace.py +330 -0
- tigrbl/schema/__init__.py +38 -0
- tigrbl/schema/_schema.py +27 -0
- tigrbl/schema/builder/__init__.py +17 -0
- tigrbl/schema/builder/build_schema.py +209 -0
- tigrbl/schema/builder/cache.py +24 -0
- tigrbl/schema/builder/compat.py +16 -0
- tigrbl/schema/builder/extras.py +85 -0
- tigrbl/schema/builder/helpers.py +51 -0
- tigrbl/schema/builder/list_params.py +117 -0
- tigrbl/schema/builder/strip_parent_fields.py +70 -0
- tigrbl/schema/collect.py +79 -0
- tigrbl/schema/decorators.py +68 -0
- tigrbl/schema/get_schema.py +86 -0
- tigrbl/schema/schema_spec.py +20 -0
- tigrbl/schema/shortcuts.py +42 -0
- tigrbl/schema/types.py +34 -0
- tigrbl/schema/utils.py +143 -0
- tigrbl/session/README.md +14 -0
- tigrbl/session/__init__.py +28 -0
- tigrbl/session/abc.py +76 -0
- tigrbl/session/base.py +151 -0
- tigrbl/session/decorators.py +43 -0
- tigrbl/session/default.py +118 -0
- tigrbl/session/shortcuts.py +50 -0
- tigrbl/session/spec.py +112 -0
- tigrbl/shortcuts.py +22 -0
- tigrbl/specs.py +44 -0
- tigrbl/system/__init__.py +13 -0
- tigrbl/system/diagnostics/__init__.py +24 -0
- tigrbl/system/diagnostics/compat.py +31 -0
- tigrbl/system/diagnostics/healthz.py +41 -0
- tigrbl/system/diagnostics/hookz.py +51 -0
- tigrbl/system/diagnostics/kernelz.py +20 -0
- tigrbl/system/diagnostics/methodz.py +43 -0
- tigrbl/system/diagnostics/router.py +73 -0
- tigrbl/system/diagnostics/utils.py +43 -0
- tigrbl/system/uvicorn.py +60 -0
- tigrbl/table/__init__.py +9 -0
- tigrbl/table/_base.py +260 -0
- tigrbl/table/_table.py +54 -0
- tigrbl/table/mro_collect.py +69 -0
- tigrbl/table/shortcuts.py +57 -0
- tigrbl/table/table_spec.py +28 -0
- tigrbl/transport/__init__.py +74 -0
- tigrbl/transport/jsonrpc/__init__.py +19 -0
- tigrbl/transport/jsonrpc/dispatcher.py +352 -0
- tigrbl/transport/jsonrpc/helpers.py +115 -0
- tigrbl/transport/jsonrpc/models.py +41 -0
- tigrbl/transport/rest/__init__.py +25 -0
- tigrbl/transport/rest/aggregator.py +132 -0
- tigrbl/types/__init__.py +170 -0
- tigrbl/types/allow_anon_provider.py +19 -0
- tigrbl/types/authn_abc.py +30 -0
- tigrbl/types/nested_path_provider.py +22 -0
- tigrbl/types/op.py +35 -0
- tigrbl/types/op_config_provider.py +17 -0
- tigrbl/types/op_verb_alias_provider.py +33 -0
- tigrbl/types/request_extras_provider.py +22 -0
- tigrbl/types/response_extras_provider.py +22 -0
- tigrbl/types/table_config_provider.py +13 -0
- tigrbl/types/uuid.py +55 -0
- tigrbl-0.3.0.dist-info/METADATA +516 -0
- tigrbl-0.3.0.dist-info/RECORD +266 -0
- {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dist-info}/WHEEL +1 -1
- tigrbl-0.3.0.dist-info/licenses/LICENSE +201 -0
- tigrbl/ExampleAgent.py +0 -1
- tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
- 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"]
|