svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/db/outbox.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OutboxMessage:
|
|
10
|
+
id: int
|
|
11
|
+
topic: str
|
|
12
|
+
payload: Dict[str, Any]
|
|
13
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
14
|
+
attempts: int = 0
|
|
15
|
+
processed_at: Optional[datetime] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutboxStore(Protocol):
|
|
19
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def fetch_next(
|
|
23
|
+
self, *, topics: Optional[Iterable[str]] = None
|
|
24
|
+
) -> Optional[OutboxMessage]:
|
|
25
|
+
"""Return the next undispatched, unprocessed message (FIFO per-topic), or None.
|
|
26
|
+
|
|
27
|
+
Notes:
|
|
28
|
+
- Messages with attempts > 0 are considered "dispatched" to the job queue and won't be re-enqueued.
|
|
29
|
+
- Delivery retries are handled by the job queue worker, not by re-reading the outbox.
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InMemoryOutboxStore:
|
|
41
|
+
"""Simple in-memory outbox for tests and local runs."""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self._seq = 0
|
|
45
|
+
self._messages: List[OutboxMessage] = []
|
|
46
|
+
|
|
47
|
+
def enqueue(self, topic: str, payload: Dict[str, Any]) -> OutboxMessage:
|
|
48
|
+
self._seq += 1
|
|
49
|
+
msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
|
|
50
|
+
self._messages.append(msg)
|
|
51
|
+
return msg
|
|
52
|
+
|
|
53
|
+
def fetch_next(
|
|
54
|
+
self, *, topics: Optional[Iterable[str]] = None
|
|
55
|
+
) -> Optional[OutboxMessage]:
|
|
56
|
+
allowed = set(topics) if topics else None
|
|
57
|
+
for msg in self._messages:
|
|
58
|
+
if msg.processed_at is not None:
|
|
59
|
+
continue
|
|
60
|
+
# skip already dispatched messages (attempts>0)
|
|
61
|
+
if msg.attempts > 0:
|
|
62
|
+
continue
|
|
63
|
+
if allowed is not None and msg.topic not in allowed:
|
|
64
|
+
continue
|
|
65
|
+
return msg
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
69
|
+
for msg in self._messages:
|
|
70
|
+
if msg.id == msg_id:
|
|
71
|
+
msg.processed_at = datetime.now(timezone.utc)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
75
|
+
for msg in self._messages:
|
|
76
|
+
if msg.id == msg_id:
|
|
77
|
+
msg.attempts += 1
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SqlOutboxStore:
|
|
82
|
+
"""Skeleton for a SQL-backed outbox store.
|
|
83
|
+
|
|
84
|
+
Implementations should:
|
|
85
|
+
- INSERT on enqueue
|
|
86
|
+
- SELECT FOR UPDATE SKIP LOCKED (or equivalent) to fetch next
|
|
87
|
+
- UPDATE processed_at (and attempts on failure)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, session_factory):
|
|
91
|
+
self._session_factory = session_factory
|
|
92
|
+
|
|
93
|
+
# Placeholders to outline the API; not implemented here.
|
|
94
|
+
def enqueue(
|
|
95
|
+
self, topic: str, payload: Dict[str, Any]
|
|
96
|
+
) -> OutboxMessage: # pragma: no cover - skeleton
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
def fetch_next(
|
|
100
|
+
self, *, topics: Optional[Iterable[str]] = None
|
|
101
|
+
) -> Optional[OutboxMessage]: # pragma: no cover - skeleton
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
def mark_failed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|
|
108
|
+
raise NotImplementedError
|
svc_infra/db/sql/apikey.py
CHANGED
|
@@ -7,18 +7,36 @@ import uuid
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from typing import Optional, Type
|
|
9
9
|
|
|
10
|
-
from sqlalchemy import
|
|
10
|
+
from sqlalchemy import (
|
|
11
|
+
JSON,
|
|
12
|
+
Boolean,
|
|
13
|
+
DateTime,
|
|
14
|
+
ForeignKey,
|
|
15
|
+
Index,
|
|
16
|
+
String,
|
|
17
|
+
UniqueConstraint,
|
|
18
|
+
text,
|
|
19
|
+
)
|
|
11
20
|
from sqlalchemy.ext.mutable import MutableDict, MutableList
|
|
12
21
|
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
|
|
13
22
|
|
|
23
|
+
from svc_infra.app.env import require_secret
|
|
14
24
|
from svc_infra.db.sql.base import ModelBase
|
|
15
25
|
from svc_infra.db.sql.types import GUID
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
|
|
28
|
+
def _get_apikey_secret() -> str:
|
|
29
|
+
"""Get APIKEY_HASH_SECRET, requiring it in production."""
|
|
30
|
+
return require_secret(
|
|
31
|
+
os.getenv("APIKEY_HASH_SECRET"),
|
|
32
|
+
"APIKEY_HASH_SECRET",
|
|
33
|
+
dev_default="dev-only-apikey-hmac-secret-not-for-production",
|
|
34
|
+
)
|
|
18
35
|
|
|
19
36
|
|
|
20
37
|
def _hmac_sha256(s: str) -> str:
|
|
21
|
-
|
|
38
|
+
secret = _get_apikey_secret()
|
|
39
|
+
return hmac.new(secret.encode(), s.encode(), hashlib.sha256).hexdigest()
|
|
22
40
|
|
|
23
41
|
|
|
24
42
|
def _now() -> datetime:
|
|
@@ -33,7 +51,9 @@ _ApiKeyModel: Optional[type] = None
|
|
|
33
51
|
def get_apikey_model() -> type:
|
|
34
52
|
"""Return the bound ApiKey model (or raise if not enabled)."""
|
|
35
53
|
if _ApiKeyModel is None:
|
|
36
|
-
raise RuntimeError(
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"ApiKey model is not enabled. Call bind_apikey_model(...) first."
|
|
56
|
+
)
|
|
37
57
|
return _ApiKeyModel
|
|
38
58
|
|
|
39
59
|
|
|
@@ -43,10 +63,12 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
|
|
|
43
63
|
Call this once during app boot (e.g., inside add_auth_users when enable_api_keys=True).
|
|
44
64
|
"""
|
|
45
65
|
|
|
46
|
-
class ApiKey(ModelBase):
|
|
66
|
+
class ApiKey(ModelBase):
|
|
47
67
|
__tablename__ = table_name
|
|
48
68
|
|
|
49
|
-
id: Mapped[uuid.UUID] = mapped_column(
|
|
69
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
70
|
+
GUID(), primary_key=True, default=uuid.uuid4
|
|
71
|
+
)
|
|
50
72
|
|
|
51
73
|
@declared_attr
|
|
52
74
|
def user_id(cls) -> Mapped[uuid.UUID | None]: # noqa: N805
|
|
@@ -67,14 +89,18 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
|
|
|
67
89
|
key_prefix: Mapped[str] = mapped_column(String(12), index=True, nullable=False)
|
|
68
90
|
key_hash: Mapped[str] = mapped_column(String(64), nullable=False) # hex sha256
|
|
69
91
|
|
|
70
|
-
scopes: Mapped[list[str]] = mapped_column(
|
|
92
|
+
scopes: Mapped[list[str]] = mapped_column(
|
|
93
|
+
MutableList.as_mutable(JSON), default=list
|
|
94
|
+
)
|
|
71
95
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
72
96
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
73
97
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
74
98
|
meta: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict)
|
|
75
99
|
|
|
76
100
|
created_at = mapped_column(
|
|
77
|
-
DateTime(timezone=True),
|
|
101
|
+
DateTime(timezone=True),
|
|
102
|
+
server_default=text("CURRENT_TIMESTAMP"),
|
|
103
|
+
nullable=False,
|
|
78
104
|
)
|
|
79
105
|
updated_at = mapped_column(
|
|
80
106
|
DateTime(timezone=True),
|
|
@@ -99,7 +125,9 @@ def bind_apikey_model(user_model: Type, *, table_name: str = "api_keys") -> type
|
|
|
99
125
|
import secrets
|
|
100
126
|
|
|
101
127
|
prefix = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:8]
|
|
102
|
-
rand =
|
|
128
|
+
rand = (
|
|
129
|
+
base64.urlsafe_b64encode(secrets.token_bytes(24)).decode().rstrip("=")
|
|
130
|
+
)
|
|
103
131
|
plaintext = f"ak_{prefix}_{rand}"
|
|
104
132
|
return plaintext, prefix, _hmac_sha256(plaintext)
|
|
105
133
|
|
svc_infra/db/sql/authref.py
CHANGED
|
@@ -22,11 +22,15 @@ def _find_auth_mapper() -> Optional[Tuple[str, TypeEngine, str]]:
|
|
|
22
22
|
table = mapper.local_table or getattr(cls, "__table__", None)
|
|
23
23
|
if table is None:
|
|
24
24
|
continue
|
|
25
|
-
|
|
25
|
+
table_name = getattr(table, "name", None)
|
|
26
|
+
if not isinstance(table_name, str) or not table_name:
|
|
27
|
+
continue
|
|
28
|
+
# SQLAlchemy's primary_key is iterable; don't rely on .columns typing.
|
|
29
|
+
pk_cols = list(table.primary_key)
|
|
26
30
|
if len(pk_cols) != 1:
|
|
27
31
|
continue # require single-column PK
|
|
28
32
|
pk_col = pk_cols[0]
|
|
29
|
-
return (
|
|
33
|
+
return (table_name, pk_col.type, pk_col.name)
|
|
30
34
|
except Exception:
|
|
31
35
|
pass
|
|
32
36
|
return None
|
|
@@ -58,4 +62,6 @@ def user_fk_constraint(
|
|
|
58
62
|
Returns a table-level ForeignKeyConstraint([...], [<auth_table>.<pk>]) for the given column.
|
|
59
63
|
"""
|
|
60
64
|
table, _pk_type, pk_name = resolve_auth_table_pk()
|
|
61
|
-
return ForeignKeyConstraint(
|
|
65
|
+
return ForeignKeyConstraint(
|
|
66
|
+
[column_name], [f"{table}.{pk_name}"], ondelete=ondelete
|
|
67
|
+
)
|
svc_infra/db/sql/constants.py
CHANGED
|
@@ -4,9 +4,13 @@ import re
|
|
|
4
4
|
from typing import Sequence
|
|
5
5
|
|
|
6
6
|
# Environment variable names to look up for DB URL
|
|
7
|
+
# Order matters: svc-infra canonical names first, then common PaaS names
|
|
7
8
|
DEFAULT_DB_ENV_VARS: Sequence[str] = (
|
|
8
9
|
"SQL_URL",
|
|
9
10
|
"DB_URL",
|
|
11
|
+
"DATABASE_URL", # Heroku, Railway (public)
|
|
12
|
+
"DATABASE_URL_PRIVATE", # Railway (private networking)
|
|
13
|
+
"PRIVATE_SQL_URL", # Legacy svc-infra naming
|
|
10
14
|
)
|
|
11
15
|
|
|
12
16
|
# Regex used to detect async drivers from URL drivername
|
|
@@ -18,16 +22,16 @@ try:
|
|
|
18
22
|
import importlib.resources as pkg
|
|
19
23
|
|
|
20
24
|
_tmpl_pkg = pkg.files("svc_infra.db.sql.templates.setup")
|
|
21
|
-
ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(
|
|
22
|
-
|
|
23
|
-
except Exception:
|
|
24
|
-
# Fallbacks (should not normally happen). Provide minimal safe defaults.
|
|
25
|
-
ALEMBIC_INI_TEMPLATE = (
|
|
26
|
-
"""[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
|
|
25
|
+
ALEMBIC_INI_TEMPLATE = _tmpl_pkg.joinpath("alembic.ini.tmpl").read_text(
|
|
26
|
+
encoding="utf-8"
|
|
27
27
|
)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
ALEMBIC_SCRIPT_TEMPLATE = _tmpl_pkg.joinpath("script.py.mako.tmpl").read_text(
|
|
29
|
+
encoding="utf-8"
|
|
30
30
|
)
|
|
31
|
+
except Exception:
|
|
32
|
+
# Fallbacks (should not normally happen). Provide minimal safe defaults.
|
|
33
|
+
ALEMBIC_INI_TEMPLATE = """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
|
|
34
|
+
ALEMBIC_INI_TEMPLATE = """[alembic]\nscript_location = {script_location}\nsqlalchemy.url = {sqlalchemy_url}\n"""
|
|
31
35
|
ALEMBIC_SCRIPT_TEMPLATE = '"""${message}"""\nfrom alembic import op\nimport sqlalchemy as sa\n\nrevision = ${repr(up_revision)}\ndown_revision = ${repr(down_revision)}\nbranch_labels = ${repr(branch_labels)}\ndepends_on = ${repr(depends_on)}\n\ndef upgrade():\n ${upgrades if upgrades else "pass"}\n\n\ndef downgrade():\n ${downgrades if downgrades else "pass"}\n'
|
|
32
36
|
__all__ = [
|
|
33
37
|
"DEFAULT_DB_ENV_VARS",
|
svc_infra/db/sql/core.py
CHANGED
|
@@ -140,7 +140,7 @@ def revision(
|
|
|
140
140
|
cfg,
|
|
141
141
|
message=message,
|
|
142
142
|
autogenerate=autogenerate,
|
|
143
|
-
head=head,
|
|
143
|
+
head=head or "head",
|
|
144
144
|
branch_label=branch_label,
|
|
145
145
|
version_path=version_path,
|
|
146
146
|
sql=sql,
|
|
@@ -319,7 +319,7 @@ def setup_and_migrate(
|
|
|
319
319
|
"""
|
|
320
320
|
resolved_url = database_url or get_database_url_from_env(required=True)
|
|
321
321
|
root = prepare_env()
|
|
322
|
-
if create_db_if_missing:
|
|
322
|
+
if create_db_if_missing and resolved_url:
|
|
323
323
|
ensure_database_exists(resolved_url)
|
|
324
324
|
|
|
325
325
|
mig_dir = init_alembic(
|
svc_infra/db/sql/management.py
CHANGED
|
@@ -15,20 +15,19 @@ def _sa_columns(model: type[object]) -> list[Column]:
|
|
|
15
15
|
def _py_type(col: Column) -> type:
|
|
16
16
|
# Prefer SQLAlchemy-provided python_type when available
|
|
17
17
|
if getattr(col.type, "python_type", None):
|
|
18
|
-
return col.type.python_type
|
|
18
|
+
return col.type.python_type
|
|
19
19
|
|
|
20
20
|
from datetime import date, datetime
|
|
21
|
-
from typing import Any as _Any
|
|
22
21
|
from uuid import UUID
|
|
23
22
|
|
|
24
23
|
from sqlalchemy import JSON, Boolean, Date, DateTime, Integer, String, Text
|
|
25
24
|
|
|
26
25
|
try:
|
|
27
26
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
28
|
-
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
27
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
29
28
|
except Exception: # pragma: no cover
|
|
30
|
-
PG_UUID = None # type: ignore
|
|
31
|
-
JSONB = None # type: ignore
|
|
29
|
+
PG_UUID = None # type: ignore[misc,assignment]
|
|
30
|
+
JSONB = None # type: ignore[misc,assignment]
|
|
32
31
|
|
|
33
32
|
t = col.type
|
|
34
33
|
if PG_UUID is not None and isinstance(t, PG_UUID):
|
|
@@ -47,7 +46,7 @@ def _py_type(col: Column) -> type:
|
|
|
47
46
|
return dict
|
|
48
47
|
if JSONB is not None and isinstance(t, JSONB):
|
|
49
48
|
return dict
|
|
50
|
-
return
|
|
49
|
+
return object # fallback type for unknown column types
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
def _exclude_from_create(col: Column) -> bool:
|
|
@@ -101,9 +100,13 @@ def make_crud_schemas(
|
|
|
101
100
|
name=name,
|
|
102
101
|
typ=T,
|
|
103
102
|
required_for_create=bool(
|
|
104
|
-
is_required
|
|
103
|
+
is_required
|
|
104
|
+
and name not in explicit_excludes
|
|
105
|
+
and not _exclude_from_create(col)
|
|
106
|
+
),
|
|
107
|
+
exclude_from_create=bool(
|
|
108
|
+
name in explicit_excludes or _exclude_from_create(col)
|
|
105
109
|
),
|
|
106
|
-
exclude_from_create=bool(name in explicit_excludes or _exclude_from_create(col)),
|
|
107
110
|
exclude_from_read=bool(name in read_ex),
|
|
108
111
|
exclude_from_update=bool(name in update_ex),
|
|
109
112
|
)
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Iterable, Optional, Sequence, Set, cast
|
|
4
6
|
|
|
5
7
|
from sqlalchemy import Select, String, and_, func, or_, select
|
|
6
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
9
|
from sqlalchemy.orm import InstrumentedAttribute, class_mapper
|
|
8
10
|
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _escape_ilike(q: str) -> str:
|
|
15
|
+
"""Escape special characters for ILIKE pattern matching.
|
|
16
|
+
|
|
17
|
+
Prevents SQL injection via wildcard characters that could match
|
|
18
|
+
unintended data (e.g., % matches any string, _ matches any char).
|
|
19
|
+
"""
|
|
20
|
+
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
21
|
+
|
|
9
22
|
|
|
10
23
|
class SqlRepository:
|
|
11
24
|
"""
|
|
@@ -34,8 +47,8 @@ class SqlRepository:
|
|
|
34
47
|
def _model_columns(self) -> set[str]:
|
|
35
48
|
return {c.key for c in class_mapper(self.model).columns}
|
|
36
49
|
|
|
37
|
-
def _id_column(self) -> InstrumentedAttribute:
|
|
38
|
-
return getattr(self.model, self.id_attr)
|
|
50
|
+
def _id_column(self) -> InstrumentedAttribute[Any]:
|
|
51
|
+
return cast(InstrumentedAttribute[Any], getattr(self.model, self.id_attr))
|
|
39
52
|
|
|
40
53
|
def _base_select(self) -> Select:
|
|
41
54
|
stmt = select(self.model)
|
|
@@ -43,8 +56,12 @@ class SqlRepository:
|
|
|
43
56
|
# Filter out soft-deleted rows by timestamp and/or active flag
|
|
44
57
|
if hasattr(self.model, self.soft_delete_field):
|
|
45
58
|
stmt = stmt.where(getattr(self.model, self.soft_delete_field).is_(None))
|
|
46
|
-
if self.soft_delete_flag_field and hasattr(
|
|
47
|
-
|
|
59
|
+
if self.soft_delete_flag_field and hasattr(
|
|
60
|
+
self.model, self.soft_delete_flag_field
|
|
61
|
+
):
|
|
62
|
+
stmt = stmt.where(
|
|
63
|
+
getattr(self.model, self.soft_delete_flag_field).is_(True)
|
|
64
|
+
)
|
|
48
65
|
return stmt
|
|
49
66
|
|
|
50
67
|
# basic ops
|
|
@@ -56,20 +73,37 @@ class SqlRepository:
|
|
|
56
73
|
limit: int,
|
|
57
74
|
offset: int,
|
|
58
75
|
order_by: Optional[Sequence[Any]] = None,
|
|
76
|
+
where: Optional[Sequence[Any]] = None,
|
|
59
77
|
) -> Sequence[Any]:
|
|
60
|
-
stmt = self._base_select()
|
|
78
|
+
stmt = self._base_select()
|
|
79
|
+
if where:
|
|
80
|
+
stmt = stmt.where(and_(*where))
|
|
81
|
+
stmt = stmt.limit(limit).offset(offset)
|
|
61
82
|
if order_by:
|
|
62
83
|
stmt = stmt.order_by(*order_by)
|
|
63
|
-
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
async def count(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
result = (await session.execute(stmt)).scalars().all()
|
|
85
|
+
return list(result)
|
|
86
|
+
|
|
87
|
+
async def count(
|
|
88
|
+
self, session: AsyncSession, *, where: Optional[Sequence[Any]] = None
|
|
89
|
+
) -> int:
|
|
90
|
+
base = self._base_select()
|
|
91
|
+
if where:
|
|
92
|
+
base = base.where(and_(*where))
|
|
93
|
+
stmt = select(func.count()).select_from(base.subquery())
|
|
94
|
+
return int((await session.execute(stmt)).scalar_one())
|
|
95
|
+
|
|
96
|
+
async def get(
|
|
97
|
+
self,
|
|
98
|
+
session: AsyncSession,
|
|
99
|
+
id_value: Any,
|
|
100
|
+
*,
|
|
101
|
+
where: Optional[Sequence[Any]] = None,
|
|
102
|
+
) -> Any | None:
|
|
71
103
|
# honors soft-delete if configured
|
|
72
104
|
stmt = self._base_select().where(self._id_column() == id_value)
|
|
105
|
+
if where:
|
|
106
|
+
stmt = stmt.where(and_(*where))
|
|
73
107
|
return (await session.execute(stmt)).scalars().first()
|
|
74
108
|
|
|
75
109
|
async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
|
|
@@ -78,12 +112,18 @@ class SqlRepository:
|
|
|
78
112
|
obj = self.model(**filtered)
|
|
79
113
|
session.add(obj)
|
|
80
114
|
await session.flush()
|
|
115
|
+
await session.refresh(obj)
|
|
81
116
|
return obj
|
|
82
117
|
|
|
83
118
|
async def update(
|
|
84
|
-
self,
|
|
119
|
+
self,
|
|
120
|
+
session: AsyncSession,
|
|
121
|
+
id_value: Any,
|
|
122
|
+
data: dict[str, Any],
|
|
123
|
+
*,
|
|
124
|
+
where: Optional[Sequence[Any]] = None,
|
|
85
125
|
) -> Any | None:
|
|
86
|
-
obj = await self.get(session, id_value)
|
|
126
|
+
obj = await self.get(session, id_value, where=where)
|
|
87
127
|
if not obj:
|
|
88
128
|
return None
|
|
89
129
|
valid = self._model_columns()
|
|
@@ -91,21 +131,40 @@ class SqlRepository:
|
|
|
91
131
|
if k in valid and k not in self.immutable_fields:
|
|
92
132
|
setattr(obj, k, v)
|
|
93
133
|
await session.flush()
|
|
134
|
+
await session.refresh(obj)
|
|
94
135
|
return obj
|
|
95
136
|
|
|
96
|
-
async def delete(
|
|
97
|
-
|
|
137
|
+
async def delete(
|
|
138
|
+
self,
|
|
139
|
+
session: AsyncSession,
|
|
140
|
+
id_value: Any,
|
|
141
|
+
*,
|
|
142
|
+
where: Optional[Sequence[Any]] = None,
|
|
143
|
+
) -> bool:
|
|
144
|
+
# Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
|
|
145
|
+
if not where:
|
|
146
|
+
obj = await session.get(self.model, id_value)
|
|
147
|
+
else:
|
|
148
|
+
# Respect soft-delete and optional tenant/extra filters by selecting through base select
|
|
149
|
+
stmt = self._base_select().where(self._id_column() == id_value)
|
|
150
|
+
stmt = stmt.where(and_(*where))
|
|
151
|
+
obj = (await session.execute(stmt)).scalars().first()
|
|
98
152
|
if not obj:
|
|
99
153
|
return False
|
|
100
154
|
if self.soft_delete:
|
|
101
155
|
# Prefer timestamp, also optionally set flag to False
|
|
102
|
-
|
|
156
|
+
# Check attributes on the instance to support test doubles without class-level fields
|
|
157
|
+
if hasattr(obj, self.soft_delete_field):
|
|
103
158
|
setattr(obj, self.soft_delete_field, func.now())
|
|
104
|
-
if self.soft_delete_flag_field and hasattr(
|
|
159
|
+
if self.soft_delete_flag_field and hasattr(
|
|
160
|
+
obj, self.soft_delete_flag_field
|
|
161
|
+
):
|
|
105
162
|
setattr(obj, self.soft_delete_flag_field, False)
|
|
106
163
|
await session.flush()
|
|
107
164
|
return True
|
|
108
|
-
|
|
165
|
+
delete_result = session.delete(obj)
|
|
166
|
+
if inspect.isawaitable(delete_result):
|
|
167
|
+
await delete_result
|
|
109
168
|
await session.flush()
|
|
110
169
|
return True
|
|
111
170
|
|
|
@@ -118,18 +177,22 @@ class SqlRepository:
|
|
|
118
177
|
limit: int,
|
|
119
178
|
offset: int,
|
|
120
179
|
order_by: Optional[Sequence[Any]] = None,
|
|
180
|
+
where: Optional[Sequence[Any]] = None,
|
|
121
181
|
) -> Sequence[Any]:
|
|
122
|
-
ilike = f"%{q}%"
|
|
182
|
+
ilike = f"%{_escape_ilike(q)}%"
|
|
123
183
|
conditions = []
|
|
124
184
|
for f in fields:
|
|
125
185
|
col = getattr(self.model, f, None)
|
|
126
186
|
if col is not None:
|
|
127
187
|
try:
|
|
128
188
|
conditions.append(col.cast(String).ilike(ilike))
|
|
129
|
-
except Exception:
|
|
189
|
+
except Exception as e:
|
|
130
190
|
# skip columns that cannot be used in ilike even with cast
|
|
191
|
+
logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
|
|
131
192
|
continue
|
|
132
193
|
stmt = self._base_select()
|
|
194
|
+
if where:
|
|
195
|
+
stmt = stmt.where(and_(*where))
|
|
133
196
|
if conditions:
|
|
134
197
|
stmt = stmt.where(or_(*conditions))
|
|
135
198
|
stmt = stmt.limit(limit).offset(offset)
|
|
@@ -137,17 +200,27 @@ class SqlRepository:
|
|
|
137
200
|
stmt = stmt.order_by(*order_by)
|
|
138
201
|
return (await session.execute(stmt)).scalars().all()
|
|
139
202
|
|
|
140
|
-
async def count_filtered(
|
|
141
|
-
|
|
203
|
+
async def count_filtered(
|
|
204
|
+
self,
|
|
205
|
+
session: AsyncSession,
|
|
206
|
+
*,
|
|
207
|
+
q: str,
|
|
208
|
+
fields: Sequence[str],
|
|
209
|
+
where: Optional[Sequence[Any]] = None,
|
|
210
|
+
) -> int:
|
|
211
|
+
ilike = f"%{_escape_ilike(q)}%"
|
|
142
212
|
conditions = []
|
|
143
213
|
for f in fields:
|
|
144
214
|
col = getattr(self.model, f, None)
|
|
145
215
|
if col is not None:
|
|
146
216
|
try:
|
|
147
217
|
conditions.append(col.cast(String).ilike(ilike))
|
|
148
|
-
except Exception:
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
|
|
149
220
|
continue
|
|
150
221
|
stmt = self._base_select()
|
|
222
|
+
if where:
|
|
223
|
+
stmt = stmt.where(and_(*where))
|
|
151
224
|
if conditions:
|
|
152
225
|
stmt = stmt.where(or_(*conditions))
|
|
153
226
|
# 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
|
+
)
|
svc_infra/db/sql/scaffold.py
CHANGED
|
@@ -9,7 +9,9 @@ from svc_infra.utils import ensure_init_py, render_template, write
|
|
|
9
9
|
|
|
10
10
|
# ---------------- helpers ----------------
|
|
11
11
|
|
|
12
|
-
_INIT_CONTENT_PAIRED =
|
|
12
|
+
_INIT_CONTENT_PAIRED = (
|
|
13
|
+
'from . import models, schemas\n\n__all__ = ["models", "schemas"]\n'
|
|
14
|
+
)
|
|
13
15
|
_INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
|
|
14
16
|
|
|
15
17
|
|
|
@@ -102,7 +104,9 @@ def scaffold_core(
|
|
|
102
104
|
},
|
|
103
105
|
)
|
|
104
106
|
|
|
105
|
-
tenant_schema_field =
|
|
107
|
+
tenant_schema_field = (
|
|
108
|
+
" tenant_id: Optional[str] = None\n" if include_tenant else ""
|
|
109
|
+
)
|
|
106
110
|
schemas_txt = render_template(
|
|
107
111
|
tmpl_dir="svc_infra.db.sql.templates.models_schemas.entity",
|
|
108
112
|
name="schemas.py.tmpl",
|
svc_infra/db/sql/service.py
CHANGED
|
@@ -24,8 +24,12 @@ class SqlService:
|
|
|
24
24
|
async def pre_update(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
25
25
|
return data
|
|
26
26
|
|
|
27
|
-
async def list(
|
|
28
|
-
|
|
27
|
+
async def list(
|
|
28
|
+
self, session: AsyncSession, *, limit: int, offset: int, order_by=None
|
|
29
|
+
):
|
|
30
|
+
return await self.repo.list(
|
|
31
|
+
session, limit=limit, offset=offset, order_by=order_by
|
|
32
|
+
)
|
|
29
33
|
|
|
30
34
|
async def count(self, session: AsyncSession) -> int:
|
|
31
35
|
return await self.repo.count(session)
|
|
@@ -41,9 +45,13 @@ class SqlService:
|
|
|
41
45
|
# unique constraint or not-null -> 409/400 instead of 500
|
|
42
46
|
msg = str(e.orig) if getattr(e, "orig", None) else str(e)
|
|
43
47
|
if "duplicate key value" in msg or "UniqueViolation" in msg:
|
|
44
|
-
raise HTTPException(
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=409, detail="Record already exists."
|
|
50
|
+
) from e
|
|
45
51
|
if "not-null" in msg or "NotNullViolation" in msg:
|
|
46
|
-
raise HTTPException(
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=400, detail="Missing required field."
|
|
54
|
+
) from e
|
|
47
55
|
raise # unknown, let your error middleware turn into 500
|
|
48
56
|
|
|
49
57
|
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
@@ -67,7 +75,9 @@ class SqlService:
|
|
|
67
75
|
session, q=q, fields=fields, limit=limit, offset=offset, order_by=order_by
|
|
68
76
|
)
|
|
69
77
|
|
|
70
|
-
async def count_filtered(
|
|
78
|
+
async def count_filtered(
|
|
79
|
+
self, session: AsyncSession, *, q: str, fields: Sequence[str]
|
|
80
|
+
) -> int:
|
|
71
81
|
return await self.repo.count_filtered(session, q=q, fields=fields)
|
|
72
82
|
|
|
73
83
|
async def exists(self, session: AsyncSession, *, where):
|