svc-infra 0.1.599__py3-none-any.whl → 0.1.601__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/auth/gaurd.py +2 -2
- svc_infra/api/fastapi/db/sql/add.py +32 -13
- svc_infra/api/fastapi/db/sql/crud_router.py +153 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/middleware/ratelimit.py +41 -1
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/cli/__init__.py +2 -0
- svc_infra/cli/cmds/__init__.py +2 -0
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +82 -0
- svc_infra/db/sql/repository.py +46 -9
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/tenant.py +79 -0
- {svc_infra-0.1.599.dist-info → svc_infra-0.1.601.dist-info}/METADATA +1 -1
- {svc_infra-0.1.599.dist-info → svc_infra-0.1.601.dist-info}/RECORD +17 -13
- {svc_infra-0.1.599.dist-info → svc_infra-0.1.601.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.599.dist-info → svc_infra-0.1.601.dist-info}/entry_points.txt +0 -0
|
@@ -12,6 +12,7 @@ from fastapi_users.password import PasswordHelper
|
|
|
12
12
|
from svc_infra.api.fastapi.auth._cookies import compute_cookie_params
|
|
13
13
|
from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
|
|
14
14
|
from svc_infra.api.fastapi.auth.settings import get_auth_settings
|
|
15
|
+
from svc_infra.api.fastapi.db.sql.session import SqlSessionDep
|
|
15
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
16
17
|
|
|
17
18
|
_pwd = PasswordHelper()
|
|
@@ -66,19 +67,18 @@ def auth_session_router(
|
|
|
66
67
|
router = public_router()
|
|
67
68
|
policy = auth_policy or DefaultAuthPolicy(get_auth_settings())
|
|
68
69
|
|
|
69
|
-
from svc_infra.api.fastapi.db.sql import SqlSessionDep
|
|
70
70
|
from svc_infra.security.lockout import get_lockout_status, record_attempt
|
|
71
71
|
|
|
72
72
|
@router.post("/login", name="auth:jwt.login")
|
|
73
73
|
async def login(
|
|
74
74
|
request: Request,
|
|
75
|
+
session: SqlSessionDep,
|
|
75
76
|
username: str = Form(...),
|
|
76
77
|
password: str = Form(...),
|
|
77
78
|
scope: str = Form(""),
|
|
78
79
|
client_id: str | None = Form(None),
|
|
79
80
|
client_secret: str | None = Form(None),
|
|
80
81
|
user_manager=Depends(fapi.get_user_manager),
|
|
81
|
-
session: SqlSessionDep = Depends(),
|
|
82
82
|
):
|
|
83
83
|
strategy = auth_backend.get_strategy()
|
|
84
84
|
email = username.strip().lower()
|
|
@@ -10,7 +10,7 @@ from svc_infra.db.sql.management import make_crud_schemas
|
|
|
10
10
|
from svc_infra.db.sql.repository import SqlRepository
|
|
11
11
|
from svc_infra.db.sql.resource import SqlResource
|
|
12
12
|
|
|
13
|
-
from .crud_router import make_crud_router_plus_sql
|
|
13
|
+
from .crud_router import make_crud_router_plus_sql, make_tenant_crud_router_plus_sql
|
|
14
14
|
from .health import _make_db_health_router
|
|
15
15
|
from .session import dispose_session, initialize_session
|
|
16
16
|
|
|
@@ -37,18 +37,37 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
|
|
|
37
37
|
update_name=r.update_name,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
if r.tenant_field:
|
|
41
|
+
# wrap service factory/instance through tenant router
|
|
42
|
+
def _factory():
|
|
43
|
+
return svc
|
|
44
|
+
|
|
45
|
+
router = make_tenant_crud_router_plus_sql(
|
|
46
|
+
model=r.model,
|
|
47
|
+
service_factory=_factory,
|
|
48
|
+
read_schema=Read,
|
|
49
|
+
create_schema=Create,
|
|
50
|
+
update_schema=Update,
|
|
51
|
+
prefix=r.prefix,
|
|
52
|
+
tenant_field=r.tenant_field,
|
|
53
|
+
tags=r.tags,
|
|
54
|
+
search_fields=r.search_fields,
|
|
55
|
+
default_ordering=r.ordering_default,
|
|
56
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
router = make_crud_router_plus_sql(
|
|
60
|
+
model=r.model,
|
|
61
|
+
service=svc,
|
|
62
|
+
read_schema=Read,
|
|
63
|
+
create_schema=Create,
|
|
64
|
+
update_schema=Update,
|
|
65
|
+
prefix=r.prefix,
|
|
66
|
+
tags=r.tags,
|
|
67
|
+
search_fields=r.search_fields,
|
|
68
|
+
default_ordering=r.ordering_default,
|
|
69
|
+
allowed_order_fields=r.allowed_order_fields,
|
|
70
|
+
)
|
|
52
71
|
app.include_router(router)
|
|
53
72
|
|
|
54
73
|
|
|
@@ -15,7 +15,9 @@ from svc_infra.api.fastapi.db.http import (
|
|
|
15
15
|
)
|
|
16
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
17
17
|
from svc_infra.db.sql.service import SqlService
|
|
18
|
+
from svc_infra.db.sql.tenant import TenantSqlService
|
|
18
19
|
|
|
20
|
+
from ...tenancy.context import TenantId
|
|
19
21
|
from .session import SqlSessionDep
|
|
20
22
|
|
|
21
23
|
CreateModel = TypeVar("CreateModel", bound=BaseModel)
|
|
@@ -59,7 +61,7 @@ def make_crud_router_plus_sql(
|
|
|
59
61
|
# -------- LIST --------
|
|
60
62
|
@router.get(
|
|
61
63
|
"",
|
|
62
|
-
response_model=cast(Any, Page[
|
|
64
|
+
response_model=cast(Any, Page[Any]),
|
|
63
65
|
description=f"List items of type {model.__name__}",
|
|
64
66
|
)
|
|
65
67
|
async def list_items(
|
|
@@ -92,7 +94,7 @@ def make_crud_router_plus_sql(
|
|
|
92
94
|
# -------- GET by id --------
|
|
93
95
|
@router.get(
|
|
94
96
|
"/{item_id}",
|
|
95
|
-
response_model=cast(Any,
|
|
97
|
+
response_model=cast(Any, Any),
|
|
96
98
|
description=f"Get item of type {model.__name__}",
|
|
97
99
|
)
|
|
98
100
|
async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
@@ -104,29 +106,39 @@ def make_crud_router_plus_sql(
|
|
|
104
106
|
# -------- CREATE --------
|
|
105
107
|
@router.post(
|
|
106
108
|
"",
|
|
107
|
-
response_model=cast(Any,
|
|
109
|
+
response_model=cast(Any, Any),
|
|
108
110
|
status_code=201,
|
|
109
111
|
description=f"Create item of type {model.__name__}",
|
|
110
112
|
)
|
|
111
113
|
async def create_item(
|
|
112
114
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
113
|
-
payload:
|
|
115
|
+
payload: Any = Body(...),
|
|
114
116
|
):
|
|
115
|
-
|
|
117
|
+
if isinstance(payload, BaseModel):
|
|
118
|
+
data = payload.model_dump(exclude_unset=True)
|
|
119
|
+
elif isinstance(payload, dict):
|
|
120
|
+
data = payload
|
|
121
|
+
else:
|
|
122
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
123
|
return await service.create(session, data)
|
|
117
124
|
|
|
118
125
|
# -------- UPDATE --------
|
|
119
126
|
@router.patch(
|
|
120
127
|
"/{item_id}",
|
|
121
|
-
response_model=cast(Any,
|
|
128
|
+
response_model=cast(Any, Any),
|
|
122
129
|
description=f"Update item of type {model.__name__}",
|
|
123
130
|
)
|
|
124
131
|
async def update_item(
|
|
125
132
|
item_id: Any,
|
|
126
133
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
127
|
-
payload:
|
|
134
|
+
payload: Any = Body(...),
|
|
128
135
|
):
|
|
129
|
-
|
|
136
|
+
if isinstance(payload, BaseModel):
|
|
137
|
+
data = payload.model_dump(exclude_unset=True)
|
|
138
|
+
elif isinstance(payload, dict):
|
|
139
|
+
data = payload
|
|
140
|
+
else:
|
|
141
|
+
raise HTTPException(422, "invalid_payload")
|
|
130
142
|
row = await service.update(session, item_id, data)
|
|
131
143
|
if not row:
|
|
132
144
|
raise HTTPException(404, "Not found")
|
|
@@ -145,4 +157,136 @@ def make_crud_router_plus_sql(
|
|
|
145
157
|
return router
|
|
146
158
|
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
def make_tenant_crud_router_plus_sql(
|
|
161
|
+
*,
|
|
162
|
+
model: type[Any],
|
|
163
|
+
service_factory: callable, # factory that returns a SqlService (will be wrapped)
|
|
164
|
+
read_schema: Type[ReadModel],
|
|
165
|
+
create_schema: Type[CreateModel],
|
|
166
|
+
update_schema: Type[UpdateModel],
|
|
167
|
+
prefix: str,
|
|
168
|
+
tenant_field: str = "tenant_id",
|
|
169
|
+
tags: list[str] | None = None,
|
|
170
|
+
search_fields: Optional[Sequence[str]] = None,
|
|
171
|
+
default_ordering: Optional[str] = None,
|
|
172
|
+
allowed_order_fields: Optional[list[str]] = None,
|
|
173
|
+
mount_under_db_prefix: bool = True,
|
|
174
|
+
) -> APIRouter:
|
|
175
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
176
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
177
|
+
router = public_router(
|
|
178
|
+
prefix=router_prefix,
|
|
179
|
+
tags=tags or [prefix.strip("/")],
|
|
180
|
+
redirect_slashes=False,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
184
|
+
if not order_spec:
|
|
185
|
+
return []
|
|
186
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
187
|
+
fields: list[str] = []
|
|
188
|
+
for p in pieces:
|
|
189
|
+
name = p[1:] if p.startswith("-") else p
|
|
190
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
191
|
+
continue
|
|
192
|
+
fields.append(p)
|
|
193
|
+
return fields
|
|
194
|
+
|
|
195
|
+
# create per-request service with tenant scoping
|
|
196
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
197
|
+
base = service_factory # consumer-provided factory or instance
|
|
198
|
+
svc = base # assume already a SqlService by default
|
|
199
|
+
if callable(base):
|
|
200
|
+
svc = base # the consumer likely closed over repo
|
|
201
|
+
# if callable returns a service, call it now
|
|
202
|
+
try:
|
|
203
|
+
svc = base() # type: ignore[misc]
|
|
204
|
+
except TypeError:
|
|
205
|
+
svc = base # already instance
|
|
206
|
+
if not isinstance(svc, TenantSqlService):
|
|
207
|
+
svc = TenantSqlService(getattr(svc, "repo", svc), tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
208
|
+
return svc # type: ignore[return-value]
|
|
209
|
+
|
|
210
|
+
@router.get("", response_model=cast(Any, Page[Any]))
|
|
211
|
+
async def list_items(
|
|
212
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
213
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
214
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
215
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
216
|
+
tenant_id: TenantId,
|
|
217
|
+
):
|
|
218
|
+
svc = await _svc(session, tenant_id)
|
|
219
|
+
order_spec = op.order_by or default_ordering
|
|
220
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
221
|
+
order_by = build_order_by(model, order_fields)
|
|
222
|
+
if sp.q:
|
|
223
|
+
fields = [
|
|
224
|
+
f.strip()
|
|
225
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
226
|
+
if f.strip()
|
|
227
|
+
]
|
|
228
|
+
items = await svc.search(
|
|
229
|
+
session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
230
|
+
)
|
|
231
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
232
|
+
else:
|
|
233
|
+
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
234
|
+
total = await svc.count(session)
|
|
235
|
+
return Page[read_schema].from_items(
|
|
236
|
+
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@router.get("/{item_id}", response_model=cast(Any, Any))
|
|
240
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
241
|
+
svc = await _svc(session, tenant_id)
|
|
242
|
+
row = await svc.get(session, item_id)
|
|
243
|
+
if not row:
|
|
244
|
+
raise HTTPException(404, "Not found")
|
|
245
|
+
return row
|
|
246
|
+
|
|
247
|
+
@router.post("", response_model=cast(Any, Any), status_code=201)
|
|
248
|
+
async def create_item(
|
|
249
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
250
|
+
tenant_id: TenantId,
|
|
251
|
+
payload: Any = Body(...),
|
|
252
|
+
):
|
|
253
|
+
svc = await _svc(session, tenant_id)
|
|
254
|
+
if isinstance(payload, BaseModel):
|
|
255
|
+
data = payload.model_dump(exclude_unset=True)
|
|
256
|
+
elif isinstance(payload, dict):
|
|
257
|
+
data = payload
|
|
258
|
+
else:
|
|
259
|
+
raise HTTPException(422, "invalid_payload")
|
|
260
|
+
return await svc.create(session, data)
|
|
261
|
+
|
|
262
|
+
@router.patch("/{item_id}", response_model=cast(Any, Any))
|
|
263
|
+
async def update_item(
|
|
264
|
+
item_id: Any,
|
|
265
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
266
|
+
tenant_id: TenantId,
|
|
267
|
+
payload: Any = Body(...),
|
|
268
|
+
):
|
|
269
|
+
svc = await _svc(session, tenant_id)
|
|
270
|
+
if isinstance(payload, BaseModel):
|
|
271
|
+
data = payload.model_dump(exclude_unset=True)
|
|
272
|
+
elif isinstance(payload, dict):
|
|
273
|
+
data = payload
|
|
274
|
+
else:
|
|
275
|
+
raise HTTPException(422, "invalid_payload")
|
|
276
|
+
row = await svc.update(session, item_id, data)
|
|
277
|
+
if not row:
|
|
278
|
+
raise HTTPException(404, "Not found")
|
|
279
|
+
return row
|
|
280
|
+
|
|
281
|
+
@router.delete("/{item_id}", status_code=204)
|
|
282
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
283
|
+
svc = await _svc(session, tenant_id)
|
|
284
|
+
ok = await svc.delete(session, item_id)
|
|
285
|
+
if not ok:
|
|
286
|
+
raise HTTPException(404, "Not found")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
return router
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Callable
|
|
4
|
+
from typing import Callable, Optional
|
|
5
5
|
|
|
6
6
|
from fastapi import HTTPException
|
|
7
7
|
from starlette.requests import Request
|
|
8
8
|
|
|
9
9
|
from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
10
15
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
11
16
|
|
|
12
17
|
|
|
@@ -17,20 +22,44 @@ class RateLimiter:
|
|
|
17
22
|
limit: int,
|
|
18
23
|
window: int = 60,
|
|
19
24
|
key_fn: Callable = lambda r: "global",
|
|
25
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
26
|
+
scope_by_tenant: bool = False,
|
|
20
27
|
store: RateLimitStore | None = None,
|
|
21
28
|
):
|
|
22
29
|
self.limit = limit
|
|
23
30
|
self.window = window
|
|
24
31
|
self.key_fn = key_fn
|
|
32
|
+
self._limit_resolver = limit_resolver
|
|
33
|
+
self.scope_by_tenant = scope_by_tenant
|
|
25
34
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
26
35
|
|
|
27
36
|
async def __call__(self, request: Request):
|
|
37
|
+
# Try resolving tenant when asked
|
|
38
|
+
tenant_id = None
|
|
39
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
40
|
+
try:
|
|
41
|
+
if _resolve_tenant_id is not None:
|
|
42
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
43
|
+
except Exception:
|
|
44
|
+
tenant_id = None
|
|
45
|
+
|
|
28
46
|
key = self.key_fn(request)
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
if self.scope_by_tenant and tenant_id:
|
|
48
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
49
|
+
|
|
50
|
+
eff_limit = self.limit
|
|
51
|
+
if self._limit_resolver:
|
|
52
|
+
try:
|
|
53
|
+
v = self._limit_resolver(request, tenant_id)
|
|
54
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
55
|
+
except Exception:
|
|
56
|
+
eff_limit = self.limit
|
|
57
|
+
|
|
58
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
59
|
+
if count > eff_limit:
|
|
31
60
|
retry = max(0, reset - int(time.time()))
|
|
32
61
|
try:
|
|
33
|
-
emit_rate_limited(str(key),
|
|
62
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
34
63
|
except Exception:
|
|
35
64
|
pass
|
|
36
65
|
raise HTTPException(
|
|
@@ -46,17 +75,38 @@ def rate_limiter(
|
|
|
46
75
|
limit: int,
|
|
47
76
|
window: int = 60,
|
|
48
77
|
key_fn: Callable = lambda r: "global",
|
|
78
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
79
|
+
scope_by_tenant: bool = False,
|
|
49
80
|
store: RateLimitStore | None = None,
|
|
50
81
|
):
|
|
51
82
|
store_ = store or InMemoryRateLimitStore(limit=limit)
|
|
52
83
|
|
|
53
84
|
async def dep(request: Request):
|
|
85
|
+
tenant_id = None
|
|
86
|
+
if scope_by_tenant or limit_resolver:
|
|
87
|
+
try:
|
|
88
|
+
if _resolve_tenant_id is not None:
|
|
89
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
90
|
+
except Exception:
|
|
91
|
+
tenant_id = None
|
|
92
|
+
|
|
54
93
|
key = key_fn(request)
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
if scope_by_tenant and tenant_id:
|
|
95
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
96
|
+
|
|
97
|
+
eff_limit = limit
|
|
98
|
+
if limit_resolver:
|
|
99
|
+
try:
|
|
100
|
+
v = limit_resolver(request, tenant_id)
|
|
101
|
+
eff_limit = int(v) if v is not None else limit
|
|
102
|
+
except Exception:
|
|
103
|
+
eff_limit = limit
|
|
104
|
+
|
|
105
|
+
count, _store_limit, reset = store_.incr(str(key), window)
|
|
106
|
+
if count > eff_limit:
|
|
57
107
|
retry = max(0, reset - int(time.time()))
|
|
58
108
|
try:
|
|
59
|
-
emit_rate_limited(str(key),
|
|
109
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
60
110
|
except Exception:
|
|
61
111
|
pass
|
|
62
112
|
raise HTTPException(
|
|
@@ -7,6 +7,12 @@ from svc_infra.obs.metrics import emit_rate_limited
|
|
|
7
7
|
|
|
8
8
|
from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
9
9
|
|
|
10
|
+
try:
|
|
11
|
+
# Optional import: tenancy may not be enabled in all apps
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
12
18
|
def __init__(
|
|
@@ -15,18 +21,52 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
15
21
|
limit: int = 120,
|
|
16
22
|
window: int = 60,
|
|
17
23
|
key_fn=None,
|
|
24
|
+
*,
|
|
25
|
+
# When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
|
|
26
|
+
# Signature: (request: Request, tenant_id: Optional[str]) -> int | None
|
|
27
|
+
limit_resolver=None,
|
|
28
|
+
# If True, automatically scopes the bucket key by tenant id when available
|
|
29
|
+
scope_by_tenant: bool = False,
|
|
18
30
|
store: RateLimitStore | None = None,
|
|
19
31
|
):
|
|
20
32
|
super().__init__(app)
|
|
21
33
|
self.limit, self.window = limit, window
|
|
22
34
|
self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
|
|
35
|
+
self._limit_resolver = limit_resolver
|
|
36
|
+
self.scope_by_tenant = scope_by_tenant
|
|
23
37
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
24
38
|
|
|
25
39
|
async def dispatch(self, request, call_next):
|
|
40
|
+
# Resolve tenant when possible
|
|
41
|
+
tenant_id = None
|
|
42
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
43
|
+
try:
|
|
44
|
+
if _resolve_tenant_id is not None:
|
|
45
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
46
|
+
except Exception:
|
|
47
|
+
tenant_id = None
|
|
48
|
+
|
|
26
49
|
key = self.key_fn(request)
|
|
50
|
+
if self.scope_by_tenant and tenant_id:
|
|
51
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
52
|
+
|
|
53
|
+
# Allow dynamic limit overrides
|
|
54
|
+
eff_limit = self.limit
|
|
55
|
+
if self._limit_resolver:
|
|
56
|
+
try:
|
|
57
|
+
v = self._limit_resolver(request, tenant_id)
|
|
58
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
59
|
+
except Exception:
|
|
60
|
+
eff_limit = self.limit
|
|
61
|
+
|
|
27
62
|
now = int(time.time())
|
|
28
63
|
# Increment counter in store
|
|
29
|
-
|
|
64
|
+
# Update store limit if it differs; stores capture configured limit internally
|
|
65
|
+
# For in-memory store, we can temporarily adjust per-request by swapping a new store instance
|
|
66
|
+
# but to keep API simple, we reuse store and clamp by eff_limit below.
|
|
67
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
68
|
+
# Enforce the effective limit selected for this request
|
|
69
|
+
limit = eff_limit
|
|
30
70
|
remaining = max(0, limit - count)
|
|
31
71
|
|
|
32
72
|
if remaining < 0: # defensive clamp
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x # type: ignore[misc]
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|
svc_infra/cli/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ from svc_infra.cli.cmds import (
|
|
|
9
9
|
register_mongo,
|
|
10
10
|
register_mongo_scaffold,
|
|
11
11
|
register_obs,
|
|
12
|
+
register_sql_export,
|
|
12
13
|
register_sql_scaffold,
|
|
13
14
|
)
|
|
14
15
|
from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
@@ -19,6 +20,7 @@ pre_cli(app)
|
|
|
19
20
|
# --- sql commands ---
|
|
20
21
|
register_alembic(app)
|
|
21
22
|
register_sql_scaffold(app)
|
|
23
|
+
register_sql_export(app)
|
|
22
24
|
|
|
23
25
|
# --- nosql commands ---
|
|
24
26
|
register_mongo(app)
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
|
3
3
|
register as register_mongo_scaffold,
|
|
4
4
|
)
|
|
5
5
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
|
+
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
6
7
|
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
|
|
7
8
|
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
8
9
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
@@ -12,6 +13,7 @@ from .help import _HELP
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"register_alembic",
|
|
14
15
|
"register_sql_scaffold",
|
|
16
|
+
"register_sql_export",
|
|
15
17
|
"register_mongo",
|
|
16
18
|
"register_mongo_scaffold",
|
|
17
19
|
"register_obs",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from svc_infra.db.sql.utils import build_engine
|
|
14
|
+
|
|
15
|
+
try: # SQLAlchemy async extras are optional
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
17
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
18
|
+
AsyncEngine = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="SQL data export commands")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("export-tenant")
|
|
24
|
+
def export_tenant(
|
|
25
|
+
table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
|
|
26
|
+
tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
|
|
27
|
+
tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
|
|
28
|
+
output: Optional[Path] = typer.Option(
|
|
29
|
+
None, "--output", help="Output file; defaults to stdout."
|
|
30
|
+
),
|
|
31
|
+
limit: Optional[int] = typer.Option(None, help="Max rows to export."),
|
|
32
|
+
database_url: Optional[str] = typer.Option(
|
|
33
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
34
|
+
),
|
|
35
|
+
):
|
|
36
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
37
|
+
if database_url:
|
|
38
|
+
os.environ["SQL_URL"] = database_url
|
|
39
|
+
|
|
40
|
+
url = os.getenv("SQL_URL")
|
|
41
|
+
if not url:
|
|
42
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
43
|
+
raise typer.Exit(code=2)
|
|
44
|
+
|
|
45
|
+
engine = build_engine(url)
|
|
46
|
+
rows: list[dict[str, Any]]
|
|
47
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
48
|
+
if limit and limit > 0:
|
|
49
|
+
query += " LIMIT :limit"
|
|
50
|
+
|
|
51
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
52
|
+
if limit and limit > 0:
|
|
53
|
+
params["limit"] = int(limit)
|
|
54
|
+
|
|
55
|
+
stmt = text(query)
|
|
56
|
+
|
|
57
|
+
is_async_engine = AsyncEngine is not None and isinstance(engine, AsyncEngine)
|
|
58
|
+
|
|
59
|
+
if is_async_engine:
|
|
60
|
+
assert AsyncEngine is not None # for type checkers
|
|
61
|
+
|
|
62
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
63
|
+
async with engine.connect() as conn: # type: ignore[call-arg]
|
|
64
|
+
result = await conn.execute(stmt, params)
|
|
65
|
+
return [dict(row) for row in result.mappings()]
|
|
66
|
+
|
|
67
|
+
rows = asyncio.run(_fetch())
|
|
68
|
+
else:
|
|
69
|
+
with engine.connect() as conn: # type: ignore[attr-defined]
|
|
70
|
+
result = conn.execute(stmt, params)
|
|
71
|
+
rows = [dict(row) for row in result.mappings()]
|
|
72
|
+
|
|
73
|
+
data = json.dumps(rows, indent=2)
|
|
74
|
+
if output:
|
|
75
|
+
output.write_text(data)
|
|
76
|
+
typer.echo(str(output))
|
|
77
|
+
else:
|
|
78
|
+
sys.stdout.write(data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register(app_root: typer.Typer) -> None:
|
|
82
|
+
app_root.add_typer(app, name="sql")
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -56,20 +56,31 @@ class SqlRepository:
|
|
|
56
56
|
limit: int,
|
|
57
57
|
offset: int,
|
|
58
58
|
order_by: Optional[Sequence[Any]] = None,
|
|
59
|
+
where: Optional[Sequence[Any]] = None,
|
|
59
60
|
) -> Sequence[Any]:
|
|
60
|
-
stmt = self._base_select()
|
|
61
|
+
stmt = self._base_select()
|
|
62
|
+
if where:
|
|
63
|
+
stmt = stmt.where(and_(*where))
|
|
64
|
+
stmt = stmt.limit(limit).offset(offset)
|
|
61
65
|
if order_by:
|
|
62
66
|
stmt = stmt.order_by(*order_by)
|
|
63
67
|
rows = (await session.execute(stmt)).scalars().all()
|
|
64
68
|
return rows
|
|
65
69
|
|
|
66
|
-
async def count(self, session: AsyncSession) -> int:
|
|
67
|
-
|
|
70
|
+
async def count(self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None) -> int:
|
|
71
|
+
base = self._base_select()
|
|
72
|
+
if where:
|
|
73
|
+
base = base.where(and_(*where))
|
|
74
|
+
stmt = select(func.count()).select_from(base.subquery())
|
|
68
75
|
return (await session.execute(stmt)).scalar_one()
|
|
69
76
|
|
|
70
|
-
async def get(
|
|
77
|
+
async def get(
|
|
78
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
79
|
+
) -> Any | None:
|
|
71
80
|
# honors soft-delete if configured
|
|
72
81
|
stmt = self._base_select().where(self._id_column() == id_value)
|
|
82
|
+
if where:
|
|
83
|
+
stmt = stmt.where(and_(*where))
|
|
73
84
|
return (await session.execute(stmt)).scalars().first()
|
|
74
85
|
|
|
75
86
|
async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
|
|
@@ -81,9 +92,14 @@ class SqlRepository:
|
|
|
81
92
|
return obj
|
|
82
93
|
|
|
83
94
|
async def update(
|
|
84
|
-
self,
|
|
95
|
+
self,
|
|
96
|
+
session: AsyncSession,
|
|
97
|
+
id_value: Any,
|
|
98
|
+
data: dict[str, Any],
|
|
99
|
+
*,
|
|
100
|
+
where: Optional[Sequence[Any]] = None,
|
|
85
101
|
) -> Any | None:
|
|
86
|
-
obj = await self.get(session, id_value)
|
|
102
|
+
obj = await self.get(session, id_value, where=where)
|
|
87
103
|
if not obj:
|
|
88
104
|
return None
|
|
89
105
|
valid = self._model_columns()
|
|
@@ -93,8 +109,17 @@ class SqlRepository:
|
|
|
93
109
|
await session.flush()
|
|
94
110
|
return obj
|
|
95
111
|
|
|
96
|
-
async def delete(
|
|
97
|
-
|
|
112
|
+
async def delete(
|
|
113
|
+
self, session: AsyncSession, id_value: Any, *, where: Optional[Sequence[Any]] = None
|
|
114
|
+
) -> bool:
|
|
115
|
+
# Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
|
|
116
|
+
if not where:
|
|
117
|
+
obj = await session.get(self.model, id_value)
|
|
118
|
+
else:
|
|
119
|
+
# Respect soft-delete and optional tenant/extra filters by selecting through base select
|
|
120
|
+
stmt = self._base_select().where(self._id_column() == id_value)
|
|
121
|
+
stmt = stmt.where(and_(*where))
|
|
122
|
+
obj = (await session.execute(stmt)).scalars().first()
|
|
98
123
|
if not obj:
|
|
99
124
|
return False
|
|
100
125
|
if self.soft_delete:
|
|
@@ -118,6 +143,7 @@ class SqlRepository:
|
|
|
118
143
|
limit: int,
|
|
119
144
|
offset: int,
|
|
120
145
|
order_by: Optional[Sequence[Any]] = None,
|
|
146
|
+
where: Optional[Sequence[Any]] = None,
|
|
121
147
|
) -> Sequence[Any]:
|
|
122
148
|
ilike = f"%{q}%"
|
|
123
149
|
conditions = []
|
|
@@ -130,6 +156,8 @@ class SqlRepository:
|
|
|
130
156
|
# skip columns that cannot be used in ilike even with cast
|
|
131
157
|
continue
|
|
132
158
|
stmt = self._base_select()
|
|
159
|
+
if where:
|
|
160
|
+
stmt = stmt.where(and_(*where))
|
|
133
161
|
if conditions:
|
|
134
162
|
stmt = stmt.where(or_(*conditions))
|
|
135
163
|
stmt = stmt.limit(limit).offset(offset)
|
|
@@ -137,7 +165,14 @@ class SqlRepository:
|
|
|
137
165
|
stmt = stmt.order_by(*order_by)
|
|
138
166
|
return (await session.execute(stmt)).scalars().all()
|
|
139
167
|
|
|
140
|
-
async def count_filtered(
|
|
168
|
+
async def count_filtered(
|
|
169
|
+
self,
|
|
170
|
+
session: AsyncSession,
|
|
171
|
+
*,
|
|
172
|
+
q: str,
|
|
173
|
+
fields: Sequence[str],
|
|
174
|
+
where: Optional[Sequence[Any]] = None,
|
|
175
|
+
) -> int:
|
|
141
176
|
ilike = f"%{q}%"
|
|
142
177
|
conditions = []
|
|
143
178
|
for f in fields:
|
|
@@ -148,6 +183,8 @@ class SqlRepository:
|
|
|
148
183
|
except Exception:
|
|
149
184
|
continue
|
|
150
185
|
stmt = self._base_select()
|
|
186
|
+
if where:
|
|
187
|
+
stmt = stmt.where(and_(*where))
|
|
151
188
|
if conditions:
|
|
152
189
|
stmt = stmt.where(or_(*conditions))
|
|
153
190
|
# SELECT COUNT(*) FROM (<stmt>) as t
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -34,3 +34,8 @@ class SqlResource:
|
|
|
34
34
|
|
|
35
35
|
# Only a type reference; no runtime dependency on FastAPI layer
|
|
36
36
|
service_factory: Optional[Callable[[SqlRepository], "SqlService"]] = None
|
|
37
|
+
|
|
38
|
+
# Tenancy
|
|
39
|
+
tenant_field: Optional[str] = (
|
|
40
|
+
None # when set, CRUD router will require TenantId and scope by field
|
|
41
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .service import SqlService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TenantSqlService(SqlService):
|
|
11
|
+
"""
|
|
12
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
13
|
+
|
|
14
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
15
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
19
|
+
super().__init__(repo)
|
|
20
|
+
self.tenant_id = tenant_id
|
|
21
|
+
self.tenant_field = tenant_field
|
|
22
|
+
|
|
23
|
+
def _where(self) -> Sequence[Any]:
|
|
24
|
+
model = self.repo.model
|
|
25
|
+
col = getattr(model, self.tenant_field, None)
|
|
26
|
+
if col is None:
|
|
27
|
+
return []
|
|
28
|
+
return [col == self.tenant_id]
|
|
29
|
+
|
|
30
|
+
async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
|
|
31
|
+
return await self.repo.list(
|
|
32
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def count(self, session: AsyncSession) -> int:
|
|
36
|
+
return await self.repo.count(session, where=self._where())
|
|
37
|
+
|
|
38
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
39
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
40
|
+
|
|
41
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
42
|
+
data = await self.pre_create(data)
|
|
43
|
+
# inject tenant_id if model supports it and value missing
|
|
44
|
+
if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
|
|
45
|
+
data[self.tenant_field] = self.tenant_id
|
|
46
|
+
return await self.repo.create(session, data)
|
|
47
|
+
|
|
48
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
49
|
+
data = await self.pre_update(data)
|
|
50
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
51
|
+
|
|
52
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
53
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
54
|
+
|
|
55
|
+
async def search(
|
|
56
|
+
self,
|
|
57
|
+
session: AsyncSession,
|
|
58
|
+
*,
|
|
59
|
+
q: str,
|
|
60
|
+
fields: Sequence[str],
|
|
61
|
+
limit: int,
|
|
62
|
+
offset: int,
|
|
63
|
+
order_by=None,
|
|
64
|
+
):
|
|
65
|
+
return await self.repo.search(
|
|
66
|
+
session,
|
|
67
|
+
q=q,
|
|
68
|
+
fields=fields,
|
|
69
|
+
limit=limit,
|
|
70
|
+
offset=offset,
|
|
71
|
+
order_by=order_by,
|
|
72
|
+
where=self._where(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
|
|
76
|
+
return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["TenantSqlService"]
|
|
@@ -19,7 +19,7 @@ svc_infra/api/fastapi/apf_payments/setup.py,sha256=bk_LLLXyqTA-lqf0v-mdpqLEMbXB1
|
|
|
19
19
|
svc_infra/api/fastapi/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
svc_infra/api/fastapi/auth/_cookies.py,sha256=U4heUmMnLezHx8U6ksuUEpSZ6sNMJcIO0gdLpmZ5FXw,1367
|
|
21
21
|
svc_infra/api/fastapi/auth/add.py,sha256=-GOLZv-O53wfegmMQsi1wgex_IKJWBJJ4vEQNONQpnU,10014
|
|
22
|
-
svc_infra/api/fastapi/auth/gaurd.py,sha256=
|
|
22
|
+
svc_infra/api/fastapi/auth/gaurd.py,sha256=CXT5oMjxy_uQNyDRxQCxY2C4JFpMJqTTAz2j8pY5jOQ,8743
|
|
23
23
|
svc_infra/api/fastapi/auth/mfa/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
24
|
svc_infra/api/fastapi/auth/mfa/models.py,sha256=Te1cpGEBguUgul2NS50W_tHgybuu-TOIMPEBy53y9xc,1232
|
|
25
25
|
svc_infra/api/fastapi/auth/mfa/pre_auth.py,sha256=ZsXIUNnObF8wx9-sz7PXGHrSSpaY2G0Ed5wwvu8TBzA,1379
|
|
@@ -49,12 +49,12 @@ svc_infra/api/fastapi/db/nosql/mongo/crud_router.py,sha256=9z_H-3SFCFurHv_Najtlj
|
|
|
49
49
|
svc_infra/api/fastapi/db/nosql/mongo/health.py,sha256=OM4_Pig3IRSrBhz2yHweIhGlSBrW1bubY_mDt5uyORA,507
|
|
50
50
|
svc_infra/api/fastapi/db/sql/README.md,sha256=uF_k4wbNSeWR6JF_8evWkeSFidBEXMXcIiYyR6sEv48,3082
|
|
51
51
|
svc_infra/api/fastapi/db/sql/__init__.py,sha256=R9P1Vy2Uqf9gFISxChMhO9tOGciIjEymVPhpXmsY3zc,255
|
|
52
|
-
svc_infra/api/fastapi/db/sql/add.py,sha256=
|
|
53
|
-
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=
|
|
52
|
+
svc_infra/api/fastapi/db/sql/add.py,sha256=xGZnbGnP9PQtWvr5vuXA3_PXzTKldPM_cD3L0uztAvo,4768
|
|
53
|
+
svc_infra/api/fastapi/db/sql/crud_router.py,sha256=t4SYCYfzLIAnCXFfR2xDmIHjAI8septLd_wUBKFF1Xg,10816
|
|
54
54
|
svc_infra/api/fastapi/db/sql/health.py,sha256=ELLgQerooHHnvZRhGueSAc4QJsb3C4RojUGIu_U-hA4,792
|
|
55
55
|
svc_infra/api/fastapi/db/sql/session.py,sha256=DUBqKTRJAX4fqRz9B-w9eD9SpzZ8EUS862-GsjCL3ts,1869
|
|
56
56
|
svc_infra/api/fastapi/db/sql/users.py,sha256=68HGJgYVTEjKJm4-DPPC8-6nwXJoCukmgrYIIOHEUjs,5346
|
|
57
|
-
svc_infra/api/fastapi/dependencies/ratelimit.py,sha256=
|
|
57
|
+
svc_infra/api/fastapi/dependencies/ratelimit.py,sha256=DiOC-MJfqTtSydM6RAaeAsiXXL_6oZQoBLvRSpdWzs4,3794
|
|
58
58
|
svc_infra/api/fastapi/docs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
59
|
svc_infra/api/fastapi/docs/landing.py,sha256=5JqJYCxQDCWy-BeDLfkv7OBlzWQKSGWUCYXQ51hojG8,4627
|
|
60
60
|
svc_infra/api/fastapi/docs/scoped.py,sha256=AuN35Op-9fUvHQCLOBtRjd5eWSpB5C9EAW_7-Boxmfo,7540
|
|
@@ -79,7 +79,7 @@ svc_infra/api/fastapi/middleware/errors/handlers.py,sha256=9Em_Z6PXTm9gM9ulEYwY0
|
|
|
79
79
|
svc_infra/api/fastapi/middleware/idempotency.py,sha256=vnBQgMWzJVaF8oWgfw2ATjEKCyQifDeGPUc9z1N7ebE,5051
|
|
80
80
|
svc_infra/api/fastapi/middleware/idempotency_store.py,sha256=BQN_Cq_jf_cuZRhze4EF5v0lOMQXpUWoRo7CsSTprug,5528
|
|
81
81
|
svc_infra/api/fastapi/middleware/optimistic_lock.py,sha256=9lOMBI4VNIVndXnrMmgSq4qeR7xPjNR1H9d1F71M5S8,1271
|
|
82
|
-
svc_infra/api/fastapi/middleware/ratelimit.py,sha256=
|
|
82
|
+
svc_infra/api/fastapi/middleware/ratelimit.py,sha256=8A_J6JdU-cs9QYqP6Ufbp4vDkiH-H6CsZwege1nqf24,3855
|
|
83
83
|
svc_infra/api/fastapi/middleware/ratelimit_store.py,sha256=LmJR8-kkW42rzOjls9lG1SBtCKjVY7L2Y_bNKHNY3-A,2553
|
|
84
84
|
svc_infra/api/fastapi/middleware/request_id.py,sha256=Iru7ypTdK_n76lwziEGDWoVF4FKS0Ps1PMASYmzK8ek,768
|
|
85
85
|
svc_infra/api/fastapi/middleware/request_size_limit.py,sha256=AcGqaB-F7Tbhg-at7ViT4Bpifst34jFneDBlUBjgo5I,1248
|
|
@@ -100,6 +100,8 @@ svc_infra/api/fastapi/paths/user.py,sha256=z8xv_A3dPhG366ezJU1c839oHbU3tEg06rbx3
|
|
|
100
100
|
svc_infra/api/fastapi/routers/__init__.py,sha256=pbyrfVZzrMFgX11K47TvTS94yN0q-t-BdrVrGG9QyDk,9163
|
|
101
101
|
svc_infra/api/fastapi/routers/ping.py,sha256=4M1xJ5nPu_CSvHHSD2DQJKzS7Mf5m8J-Ok9bXSEwudA,540
|
|
102
102
|
svc_infra/api/fastapi/setup.py,sha256=zm7mgyhTZVFNbrRaCerBN8fDfglpWStAYLMZHfVmEjU,9574
|
|
103
|
+
svc_infra/api/fastapi/tenancy/add.py,sha256=47lhWoHjuqbgSckJBTNGz-Mo5R6W7SQ0hQDiOGC0HU0,509
|
|
104
|
+
svc_infra/api/fastapi/tenancy/context.py,sha256=nYnUKKP88Xy5mZCY7B7ob9PTDAnaKIhrj7pN6s-lBqg,3308
|
|
103
105
|
svc_infra/app/README.md,sha256=jWbgyvP90NobTgxlkwyzCZpFOS-zDSkKw0JoRNauCrs,5074
|
|
104
106
|
svc_infra/app/__init__.py,sha256=za20ALo_kvJMgf7R_kF98DWk3h9acmvhYrCMhThcSmU,209
|
|
105
107
|
svc_infra/app/env.py,sha256=AOs4ksLZDXoAWrXeSnkYZ2yyS4UP68QUYfaWDcQ1QfA,4426
|
|
@@ -119,9 +121,9 @@ svc_infra/cache/resources.py,sha256=BhvPAZvCQ-fitUdniGEOOE4g1ZvljdCA_R5pR8WfJz4,
|
|
|
119
121
|
svc_infra/cache/tags.py,sha256=9URw4BRlnb4QFAYpDI36fMms6642xq4TeV9jqsEjzE8,2625
|
|
120
122
|
svc_infra/cache/ttl.py,sha256=_lWvNx1CTE4RcFEOUYkADd7_k4I13SLmtK0AMRUq2OM,1945
|
|
121
123
|
svc_infra/cache/utils.py,sha256=-LWr5IiJCNm3pwaoeCVlxNknnO2ChNKFcAGlFU98kjg,4856
|
|
122
|
-
svc_infra/cli/__init__.py,sha256=
|
|
124
|
+
svc_infra/cli/__init__.py,sha256=rWsQDwgYVZifCP0tgB8DKm0uPSX56qd9ipiYsVwD-eM,749
|
|
123
125
|
svc_infra/cli/__main__.py,sha256=5BjNuyet8AY-POwoF5rGt722rHQ7tJ0Vf0UFUfzzi-I,58
|
|
124
|
-
svc_infra/cli/cmds/__init__.py,sha256=
|
|
126
|
+
svc_infra/cli/cmds/__init__.py,sha256=wlWXa2PPxfmARkqEDQODhjCBrGJoti9eX8eE1IruX7w,804
|
|
125
127
|
svc_infra/cli/cmds/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
126
128
|
svc_infra/cli/cmds/db/nosql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
127
129
|
svc_infra/cli/cmds/db/nosql/mongo/README.md,sha256=0u3XLeoBd0XQzXwwfEiFISMIij11TJ9iOGzrysBvsFk,1788
|
|
@@ -130,6 +132,7 @@ svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py,sha256=83_I0-63aRyR2uRLhpG1DKavH
|
|
|
130
132
|
svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py,sha256=gsv7V3eGxyhQQm4J8IPYV9xQkdv0DoX7txcPLgbiejk,4277
|
|
131
133
|
svc_infra/cli/cmds/db/sql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
132
134
|
svc_infra/cli/cmds/db/sql/alembic_cmds.py,sha256=KjumtKSOZR1UxbpZUuqllpknerDLNcY-0kqqqxiOnL4,7664
|
|
135
|
+
svc_infra/cli/cmds/db/sql/sql_export_cmds.py,sha256=6MxoQO-9upoXg0cl1RHIqz96yXFVGidiBYp_ewhB0E0,2700
|
|
133
136
|
svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py,sha256=eNTCqHXOxgl9H3WTbGVn9BHXYwCpjIEJsDqhEFdrYMM,4613
|
|
134
137
|
svc_infra/cli/cmds/help.py,sha256=wGfZFMYaR2ZPwW2JwKDU7M3m4AtdCd8GRQ412AmEBUM,758
|
|
135
138
|
svc_infra/cli/cmds/jobs/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
@@ -172,8 +175,8 @@ svc_infra/db/sql/base.py,sha256=yeOM4Xo2CtTzsfx0hN8KLFTgOPZp6BrbV7Omz_bZUMg,317
|
|
|
172
175
|
svc_infra/db/sql/constants.py,sha256=FqtoBWqP13gW8avIWpr8E41PDW5aQ88tjGmnokCV9Lk,1656
|
|
173
176
|
svc_infra/db/sql/core.py,sha256=2W7o1uyEZBDbqX1ptArP5Fa5obaVpZqHpczJCdf1NZk,10855
|
|
174
177
|
svc_infra/db/sql/management.py,sha256=bVpzj8BapwcghxAwmBsTnntWf3kaNf7CYETndppaiUM,3620
|
|
175
|
-
svc_infra/db/sql/repository.py,sha256=
|
|
176
|
-
svc_infra/db/sql/resource.py,sha256=
|
|
178
|
+
svc_infra/db/sql/repository.py,sha256=SG3cBf0BUnfds47BuOWjZJiFtrd2SmRkMINGioUhVn0,7047
|
|
179
|
+
svc_infra/db/sql/resource.py,sha256=CfBmRvMq_1-cgYBS9uc_G4i1W5e6wk6zo4MGdKTPT9I,1194
|
|
177
180
|
svc_infra/db/sql/scaffold.py,sha256=aRC572W9Jqs1nLprR_zmmgERthMlJwoUjQ_xbRh53RU,9107
|
|
178
181
|
svc_infra/db/sql/service.py,sha256=Jtb89FQgXWNRrh_bPLCvNyM7Up0_rn_wx1_CGWfiqNs,2790
|
|
179
182
|
svc_infra/db/sql/service_with_hooks.py,sha256=E2VHC-nqDp3AAop37WIM9nWyfTOsxzsa7W6jCFWxl9Q,721
|
|
@@ -188,6 +191,7 @@ svc_infra/db/sql/templates/setup/alembic.ini.tmpl,sha256=7SHSOyUq9S7XqVdKpWLJW7g
|
|
|
188
191
|
svc_infra/db/sql/templates/setup/env_async.py.tmpl,sha256=inKnexIhdnvwQ9gy-ODBgK1mdSKpzw2Q3pdTS_9AN0A,12781
|
|
189
192
|
svc_infra/db/sql/templates/setup/env_sync.py.tmpl,sha256=SP6OocHQIfkZl9g9iV0qVgkKOgQyViURQmTVqIIicPw,14195
|
|
190
193
|
svc_infra/db/sql/templates/setup/script.py.mako.tmpl,sha256=RiEMqF6dTN0S_lSRr90w5K2VtyK0_C81kBPZ02qQDZU,588
|
|
194
|
+
svc_infra/db/sql/tenant.py,sha256=erCB_TjZ62NXXIiOjsTOV_u3mM8kj0-R7gy4RFc-NrE,2779
|
|
191
195
|
svc_infra/db/sql/types.py,sha256=aDcYS-lEb3Aw9nlc8D67wyS5rmE9ZOkblxPjPFbMM_0,863
|
|
192
196
|
svc_infra/db/sql/uniq.py,sha256=IxW7SpgOTcHEAMe2oaDnuYFvj7YOfyjCpZRXdR45yP8,2530
|
|
193
197
|
svc_infra/db/sql/uniq_hooks.py,sha256=6gCnO0_Y-rhB0p-VuY0mZ9m1u3haiLWI3Ns_iUTqF_8,4294
|
|
@@ -270,7 +274,7 @@ svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA
|
|
|
270
274
|
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
271
275
|
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
272
276
|
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
273
|
-
svc_infra-0.1.
|
|
274
|
-
svc_infra-0.1.
|
|
275
|
-
svc_infra-0.1.
|
|
276
|
-
svc_infra-0.1.
|
|
277
|
+
svc_infra-0.1.601.dist-info/METADATA,sha256=--fXg5XHvGu56tmJ-UArjNr8FjrvQ9d-75Bwww4i3bI,7839
|
|
278
|
+
svc_infra-0.1.601.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
279
|
+
svc_infra-0.1.601.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
280
|
+
svc_infra-0.1.601.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|