dpdpkit-fastapi 0.1.0a1__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.
@@ -0,0 +1,22 @@
1
+ """FastAPI adapter for dpdpkit: router, SQLAlchemy repository, migrations, scheduler, admin mount.
2
+
3
+ dpdpkit helps you implement DPDP obligations; it does not provide legal advice or guarantee compliance.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .app import ActivityMiddleware, DPDPKit, require_consent
9
+ from .http import ConsentRequiredHTTP
10
+ from .repository import SqlAlchemyRepository, make_engine
11
+
12
+ __version__ = "0.1.0a1"
13
+
14
+ __all__ = [
15
+ "ActivityMiddleware",
16
+ "ConsentRequiredHTTP",
17
+ "DPDPKit",
18
+ "SqlAlchemyRepository",
19
+ "__version__",
20
+ "make_engine",
21
+ "require_consent",
22
+ ]
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>dpdpkit admin</title>
7
+ <style>
8
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; color: #1f2328; }
9
+ code { background: #f3f4f6; padding: .1rem .3rem; border-radius: 4px; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1>dpdpkit admin</h1>
14
+ <p>The admin dashboard ships in v0.4. Until then, use the admin API under this mount's parent path:</p>
15
+ <ul>
16
+ <li><code>GET ../requests</code> — request queue</li>
17
+ <li><code>GET ../retention/preview</code> — upcoming warnings and erasures</li>
18
+ <li><code>POST ../ledger/verify</code> — hash-chain check</li>
19
+ <li><code>GET ../audit/export</code> — evidence pack</li>
20
+ </ul>
21
+ <p>dpdpkit helps you implement DPDP obligations; it does not provide legal advice or guarantee compliance.</p>
22
+ </body>
23
+ </html>
dpdpkit/fastapi/app.py ADDED
@@ -0,0 +1,286 @@
1
+ """``DPDPKit`` — the object a FastAPI developer creates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.concurrency import run_in_threadpool
16
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
17
+ from starlette.responses import Response
18
+
19
+ from dpdpkit import Kit, Registry
20
+ from dpdpkit.config import kit_from_config, load_config, sync_notice
21
+ from dpdpkit.errors import ConfigError, DpdpkitError
22
+ from dpdpkit.kit import ContactResolver
23
+ from dpdpkit.models import Role
24
+ from dpdpkit.notify import Notifier
25
+ from dpdpkit.policy import Policy
26
+ from dpdpkit.repository import Repository
27
+ from dpdpkit.rights import IdentityVerifier
28
+
29
+ from .http import ConsentRequiredHTTP, Forbidden, Unauthenticated, consent_required_handler, render_dpdpkit_error
30
+ from .repository import SqlAlchemyRepository
31
+ from .router import build_router
32
+
33
+ log = logging.getLogger("dpdpkit.fastapi")
34
+
35
+ PrincipalResolver = Callable[[Request], "str | Awaitable[str | None] | None"]
36
+ AdminGuard = Callable[[Request], "Role | str | bool | Awaitable[Role | str | bool | None] | None"]
37
+
38
+ _default: DPDPKit | None = None
39
+
40
+
41
+ async def _maybe_await(value: Any) -> Any:
42
+ return await value if inspect.isawaitable(value) else value
43
+
44
+
45
+ class DPDPKit:
46
+ """Wire dpdpkit into a FastAPI app.
47
+
48
+ ``principal_resolver(request)`` returns a stable, non-personal reference for the signed-in user
49
+ (for example the user's primary key), or ``None``. ``admin_guard(request)`` returns a role
50
+ (``viewer``/``handler``/``admin``), ``True`` for admin, or ``None``/``False`` for no access.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ principal_resolver: PrincipalResolver,
57
+ admin_guard: AdminGuard | None = None,
58
+ db_url: str | None = None,
59
+ repository: Repository | None = None,
60
+ config: str | Path | Mapping[str, Any] | None = None,
61
+ policy: str | Policy | None = None,
62
+ overlays: Iterable[str] = (),
63
+ registry: Registry | None = None,
64
+ notifier: Notifier | None = None,
65
+ contact: Mapping[str, str] | None = None,
66
+ contact_resolver: ContactResolver | None = None,
67
+ identity_verifier: IdentityVerifier | None = None,
68
+ signing_key: bytes | str | None = None,
69
+ tenant_id: str = "default",
70
+ migrate_on_startup: bool = False,
71
+ create_tables: bool = False,
72
+ **kit_kwargs: Any,
73
+ ) -> None:
74
+ if repository is None:
75
+ if not db_url:
76
+ raise ConfigError("pass db_url or repository")
77
+ repository = SqlAlchemyRepository(db_url)
78
+ self.repository = repository
79
+ if migrate_on_startup:
80
+ self.migrate()
81
+ elif create_tables and isinstance(repository, SqlAlchemyRepository):
82
+ repository.create_all()
83
+
84
+ common: dict[str, Any] = {
85
+ "notifier": notifier,
86
+ "contact_resolver": contact_resolver,
87
+ "identity_verifier": identity_verifier,
88
+ "signing_key": signing_key,
89
+ "tenant_id": tenant_id,
90
+ **kit_kwargs,
91
+ }
92
+ self.config: dict[str, Any] | None = None
93
+ if config is not None:
94
+ self.config = load_config(config) if isinstance(config, (str, Path)) else dict(config)
95
+ self.kit = kit_from_config(self.config, repository, registry=registry, contact=contact, **common)
96
+ sync_notice(self.kit, self.config)
97
+ else:
98
+ if policy is None:
99
+ raise ConfigError("pass config= (a dpdpkit.yaml) or policy=")
100
+ self.kit = Kit(repository, policy, overlays=overlays, registry=registry, contact=contact, **common)
101
+
102
+ self.principal_resolver = principal_resolver
103
+ self.admin_guard = admin_guard
104
+ self.router = build_router(self)
105
+ self._scheduler: Any = None
106
+
107
+ global _default
108
+ _default = self
109
+
110
+ # ------------------------------------------------------------------ identity
111
+
112
+ async def resolve_principal(self, request: Request) -> str | None:
113
+ value = await _maybe_await(self.principal_resolver(request))
114
+ return str(value) if value else None
115
+
116
+ async def require_principal(self, request: Request) -> str:
117
+ principal = await self.resolve_principal(request)
118
+ if not principal:
119
+ raise Unauthenticated("sign in to use this endpoint")
120
+ return principal
121
+
122
+ async def resolve_role(self, request: Request) -> Role | None:
123
+ if self.admin_guard is None:
124
+ return None
125
+ value = await _maybe_await(self.admin_guard(request))
126
+ if value is True:
127
+ return Role.ADMIN
128
+ if not value:
129
+ return None
130
+ try:
131
+ return Role(value)
132
+ except ValueError:
133
+ log.warning("admin_guard returned unknown role %r; treating as no access", value)
134
+ return None
135
+
136
+ async def require_role(self, request: Request, needed: Role) -> str:
137
+ """Check the caller's admin role; returns an actor label for the audit trail."""
138
+ role = await self.resolve_role(request)
139
+ if role is None:
140
+ principal = await self.resolve_principal(request)
141
+ if principal is None:
142
+ raise Unauthenticated("sign in to use this endpoint")
143
+ raise Forbidden("an admin role is required")
144
+ if not role.allows(needed):
145
+ raise Forbidden(f"the {needed.value} role is required", role=role.value)
146
+ principal = await self.resolve_principal(request)
147
+ return f"{role.value}:{principal}" if principal else role.value
148
+
149
+ @staticmethod
150
+ def request_metadata(request: Request) -> dict[str, Any]:
151
+ """Evidence stored with consent events. No IP address or personal data by default."""
152
+ return {"channel": "web", "user_agent_present": "user-agent" in request.headers}
153
+
154
+ # ------------------------------------------------------------------ consent dependency
155
+
156
+ def require_consent(self, purpose: str) -> Callable[[Request], Awaitable[None]]:
157
+ async def dependency(request: Request) -> None:
158
+ principal = await self.require_principal(request)
159
+ allowed = await run_in_threadpool(self.kit.consent.check, principal, purpose)
160
+ if not allowed:
161
+ raise ConsentRequiredHTTP(purpose)
162
+
163
+ return dependency
164
+
165
+ # ------------------------------------------------------------------ app wiring
166
+
167
+ def install(
168
+ self,
169
+ app: FastAPI,
170
+ *,
171
+ prefix: str = "/dpdp",
172
+ admin_path: str | None = None,
173
+ track_activity: bool = True,
174
+ ) -> None:
175
+ """Include the router, error handlers, activity middleware and the admin dashboard."""
176
+ app.include_router(self.router, prefix=prefix)
177
+ self.install_error_handlers(app)
178
+ if track_activity:
179
+ app.add_middleware(ActivityMiddleware, dpdpkit=self)
180
+ self.mount_admin(app, path=admin_path or f"{prefix}/admin/ui")
181
+
182
+ def install_error_handlers(self, app: FastAPI) -> None:
183
+ app.state.dpdpkit = self
184
+
185
+ async def dpdpkit_error(_request: Request, exc: Exception) -> Response:
186
+ assert isinstance(exc, DpdpkitError)
187
+ return render_dpdpkit_error(exc)
188
+
189
+ app.add_exception_handler(DpdpkitError, dpdpkit_error)
190
+ app.add_exception_handler(ConsentRequiredHTTP, consent_required_handler)
191
+
192
+ def mount_admin(self, app: FastAPI, path: str = "/dpdp/admin/ui") -> None:
193
+ """Serve the prebuilt admin dashboard bundle. Until it ships (v0.4) a placeholder page is served."""
194
+ if not any(getattr(r, "path", None) == path for r in app.routes):
195
+ directory = resources.files("dpdpkit.fastapi") / "admin_static"
196
+ app.mount(path, StaticFiles(directory=str(directory), html=True), name="dpdpkit-admin")
197
+ if not hasattr(app.state, "dpdpkit"):
198
+ self.install_error_handlers(app)
199
+
200
+ # ------------------------------------------------------------------ jobs and schema
201
+
202
+ def start_scheduler(self, *, retention_minutes: int = 15, verify_hour: int = 2) -> Any:
203
+ """Run retention every ``retention_minutes`` and ledger verification daily (APScheduler)."""
204
+ try:
205
+ from apscheduler.schedulers.background import BackgroundScheduler # type: ignore[import-untyped]
206
+ except ImportError as exc: # pragma: no cover
207
+ raise ConfigError('install the scheduler extra: pip install "dpdpkit-fastapi[scheduler]"') from exc
208
+ scheduler = BackgroundScheduler(timezone="UTC")
209
+ scheduler.add_job(
210
+ self.kit.retention.run,
211
+ "interval",
212
+ minutes=retention_minutes,
213
+ id="dpdpkit-retention",
214
+ max_instances=1,
215
+ coalesce=True,
216
+ )
217
+ scheduler.add_job(
218
+ self.verify_ledger_job, "cron", hour=verify_hour, id="dpdpkit-ledger-verify", max_instances=1, coalesce=True
219
+ )
220
+ scheduler.start()
221
+ self._scheduler = scheduler
222
+ return scheduler
223
+
224
+ def verify_ledger_job(self) -> bool:
225
+ report = self.kit.ledger.verify_report()
226
+ if not report.ok:
227
+ log.error(
228
+ "dpdpkit ledger verification FAILED at row %s (%s): %s", report.failed_row, report.chain, report.reason
229
+ )
230
+ self.kit.events.publish("ledger.tampered", self.kit.tenant_id, report.model_dump(mode="json"))
231
+ return report.ok
232
+
233
+ def migrate(self, revision: str = "head") -> None:
234
+ from .migrations import upgrade
235
+
236
+ if not isinstance(self.repository, SqlAlchemyRepository):
237
+ raise ConfigError("migrate() needs the SQLAlchemy repository")
238
+ upgrade(self.repository.engine, revision)
239
+
240
+
241
+ def default_kit(request: Request | None = None) -> DPDPKit:
242
+ if request is not None:
243
+ found = getattr(request.app.state, "dpdpkit", None)
244
+ if isinstance(found, DPDPKit):
245
+ return found
246
+ if _default is None:
247
+ raise ConfigError("create a DPDPKit before using require_consent()")
248
+ return _default
249
+
250
+
251
+ def require_consent(purpose: str) -> Callable[[Request], Awaitable[None]]:
252
+ """FastAPI dependency: 403 ``consent_required`` unless the caller has consented to ``purpose``."""
253
+
254
+ async def dependency(request: Request) -> None:
255
+ await default_kit(request).require_consent(purpose)(request)
256
+
257
+ return dependency
258
+
259
+
260
+ class ActivityMiddleware(BaseHTTPMiddleware):
261
+ """Records principal activity (which restarts inactivity clocks and cancels warned erasures).
262
+ Throttled per principal so busy users do not write on every request."""
263
+
264
+ def __init__(self, app: Any, dpdpkit: DPDPKit, min_interval_seconds: float = 300) -> None:
265
+ super().__init__(app)
266
+ self.dpdpkit = dpdpkit
267
+ self.min_interval = min_interval_seconds
268
+ self._seen: dict[str, float] = {}
269
+
270
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
271
+ response = await call_next(request)
272
+ if response.status_code >= 400:
273
+ return response
274
+ try:
275
+ principal = await self.dpdpkit.resolve_principal(request)
276
+ except Exception:
277
+ return response
278
+ if principal:
279
+ now = time.monotonic()
280
+ if now - self._seen.get(principal, -1e12) >= self.min_interval:
281
+ self._seen[principal] = now
282
+ try:
283
+ await run_in_threadpool(self.dpdpkit.kit.retention.record_activity, principal)
284
+ except Exception:
285
+ log.exception("failed to record activity for a principal")
286
+ return response
dpdpkit/fastapi/cli.py ADDED
@@ -0,0 +1,39 @@
1
+ """Adds ``dpdpkit migrate`` to the core CLI (entry point group ``dpdpkit.cli``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any
7
+
8
+
9
+ def _migrate(args: argparse.Namespace) -> int:
10
+ from dpdpkit.cli import load_kit
11
+
12
+ from .migrations import alembic_config, upgrade
13
+ from .repository import SqlAlchemyRepository, make_engine
14
+
15
+ if args.sql:
16
+ from alembic import command
17
+
18
+ command.upgrade(alembic_config(url=args.db_url or "postgresql://"), args.revision, sql=True)
19
+ return 0
20
+ if args.db_url:
21
+ engine = make_engine(args.db_url)
22
+ else:
23
+ repo = load_kit(args.kit).repository
24
+ if not isinstance(repo, SqlAlchemyRepository):
25
+ print("error: the kit does not use the SQLAlchemy repository")
26
+ return 1
27
+ engine = repo.engine
28
+ upgrade(engine, args.revision)
29
+ print(f"database migrated to {args.revision}")
30
+ return 0
31
+
32
+
33
+ def register(subparsers: Any) -> None:
34
+ p = subparsers.add_parser("migrate", help="apply dpdpkit-fastapi database migrations")
35
+ p.add_argument("--db-url", help="SQLAlchemy URL (or use --kit)")
36
+ p.add_argument("--kit", help="module:attribute of your DPDPKit or Kit")
37
+ p.add_argument("--revision", default="head")
38
+ p.add_argument("--sql", action="store_true", help="print SQL instead of running it")
39
+ p.set_defaults(func=_migrate)
dpdpkit/fastapi/db.py ADDED
@@ -0,0 +1,152 @@
1
+ """SQLAlchemy 2.0 tables. Ledger and audit rows use real columns; mutable aggregates store their
2
+ model as a JSON document next to the indexed columns used for lookups."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import datetime as dt
7
+ from typing import Any, ClassVar
8
+
9
+ from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Index, Integer, MetaData, String, UniqueConstraint
10
+ from sqlalchemy.dialects import mysql, postgresql
11
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
12
+
13
+ NAMING = {
14
+ "ix": "ix_%(column_0_label)s",
15
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
16
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
17
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
18
+ "pk": "pk_%(table_name)s",
19
+ }
20
+
21
+ # Microsecond precision everywhere: ledger hashes include timestamps and must survive a round trip.
22
+ Timestamp = DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql")
23
+ Json = JSON().with_variant(postgresql.JSONB(), "postgresql")
24
+
25
+
26
+ class Base(DeclarativeBase):
27
+ metadata = MetaData(naming_convention=NAMING)
28
+ type_annotation_map: ClassVar[dict[Any, Any]] = {dt.datetime: Timestamp, dict[str, Any]: Json}
29
+
30
+
31
+ class ConsentEventRow(Base):
32
+ __tablename__ = "dpdp_consent_events"
33
+ __table_args__ = (
34
+ UniqueConstraint("tenant_id", "seq", name="uq_dpdp_consent_events_tenant_seq"),
35
+ Index("ix_dpdp_consent_events_lookup", "tenant_id", "principal", "purpose", "seq"),
36
+ )
37
+
38
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
39
+ tenant_id: Mapped[str] = mapped_column(String(64))
40
+ seq: Mapped[int] = mapped_column(BigInteger)
41
+ principal: Mapped[str] = mapped_column(String(255))
42
+ purpose: Mapped[str] = mapped_column(String(128))
43
+ action: Mapped[str] = mapped_column(String(16))
44
+ notice_version: Mapped[int | None] = mapped_column(Integer)
45
+ notice_hash: Mapped[str | None] = mapped_column(String(64))
46
+ locale: Mapped[str | None] = mapped_column(String(16))
47
+ source: Mapped[str] = mapped_column(String(64))
48
+ occurred_at: Mapped[dt.datetime]
49
+ meta: Mapped[dict[str, Any]]
50
+ prev_hash: Mapped[str] = mapped_column(String(64))
51
+ hash: Mapped[str] = mapped_column(String(64))
52
+
53
+
54
+ class AuditRow(Base):
55
+ __tablename__ = "dpdp_audit"
56
+ __table_args__ = (
57
+ UniqueConstraint("tenant_id", "seq", name="uq_dpdp_audit_tenant_seq"),
58
+ Index("ix_dpdp_audit_subject", "tenant_id", "subject_id"),
59
+ )
60
+
61
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
62
+ tenant_id: Mapped[str] = mapped_column(String(64))
63
+ seq: Mapped[int] = mapped_column(BigInteger)
64
+ actor: Mapped[str] = mapped_column(String(255))
65
+ action: Mapped[str] = mapped_column(String(128))
66
+ subject_type: Mapped[str] = mapped_column(String(64))
67
+ subject_id: Mapped[str] = mapped_column(String(255))
68
+ occurred_at: Mapped[dt.datetime]
69
+ data: Mapped[dict[str, Any]]
70
+ prev_hash: Mapped[str] = mapped_column(String(64))
71
+ hash: Mapped[str] = mapped_column(String(64))
72
+
73
+
74
+ class NoticeRow(Base):
75
+ __tablename__ = "dpdp_notices"
76
+
77
+ tenant_id: Mapped[str] = mapped_column(String(64), primary_key=True)
78
+ version: Mapped[int] = mapped_column(Integer, primary_key=True)
79
+ locale: Mapped[str] = mapped_column(String(16), primary_key=True)
80
+ content_hash: Mapped[str] = mapped_column(String(64))
81
+ published_at: Mapped[dt.datetime]
82
+ doc: Mapped[dict[str, Any]]
83
+
84
+
85
+ class RequestRow(Base):
86
+ __tablename__ = "dpdp_requests"
87
+ __table_args__ = (
88
+ Index("ix_dpdp_requests_principal", "tenant_id", "principal"),
89
+ Index("ix_dpdp_requests_status", "tenant_id", "status"),
90
+ )
91
+
92
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
93
+ tenant_id: Mapped[str] = mapped_column(String(64))
94
+ principal: Mapped[str] = mapped_column(String(255))
95
+ status: Mapped[str] = mapped_column(String(32))
96
+ opened_at: Mapped[dt.datetime]
97
+ doc: Mapped[dict[str, Any]]
98
+
99
+
100
+ class NomineeRow(Base):
101
+ __tablename__ = "dpdp_nominees"
102
+ __table_args__ = (Index("ix_dpdp_nominees_principal", "tenant_id", "principal"),)
103
+
104
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
105
+ tenant_id: Mapped[str] = mapped_column(String(64))
106
+ principal: Mapped[str] = mapped_column(String(255))
107
+ doc: Mapped[dict[str, Any]]
108
+
109
+
110
+ class ScheduleRow(Base):
111
+ __tablename__ = "dpdp_erasure_schedules"
112
+ __table_args__ = (
113
+ Index("ix_dpdp_schedules_due", "tenant_id", "status", "warn_at"),
114
+ Index("ix_dpdp_schedules_principal", "tenant_id", "principal"),
115
+ )
116
+
117
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
118
+ tenant_id: Mapped[str] = mapped_column(String(64))
119
+ principal: Mapped[str] = mapped_column(String(255))
120
+ purpose: Mapped[str] = mapped_column(String(128))
121
+ status: Mapped[str] = mapped_column(String(32))
122
+ warn_at: Mapped[dt.datetime]
123
+ erase_at: Mapped[dt.datetime]
124
+ doc: Mapped[dict[str, Any]]
125
+
126
+
127
+ class HoldRow(Base):
128
+ __tablename__ = "dpdp_legal_holds"
129
+ __table_args__ = (Index("ix_dpdp_holds_principal", "tenant_id", "principal", "active"),)
130
+
131
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
132
+ tenant_id: Mapped[str] = mapped_column(String(64))
133
+ principal: Mapped[str] = mapped_column(String(255))
134
+ active: Mapped[bool] = mapped_column(Boolean)
135
+ doc: Mapped[dict[str, Any]]
136
+
137
+
138
+ class ActivityRow(Base):
139
+ __tablename__ = "dpdp_activity"
140
+
141
+ tenant_id: Mapped[str] = mapped_column(String(64), primary_key=True)
142
+ principal: Mapped[str] = mapped_column(String(255), primary_key=True)
143
+ last_at: Mapped[dt.datetime]
144
+
145
+
146
+ class DeliveryRow(Base):
147
+ __tablename__ = "dpdp_deliveries"
148
+
149
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
150
+ tenant_id: Mapped[str] = mapped_column(String(64))
151
+ created_at: Mapped[dt.datetime]
152
+ doc: Mapped[dict[str, Any]]
@@ -0,0 +1,84 @@
1
+ """Error rendering and the route class that turns dpdpkit errors into the contract's ``Error`` body."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Coroutine
6
+ from typing import Any
7
+
8
+ from fastapi import HTTPException, Request, Response
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from fastapi.routing import APIRoute
12
+
13
+ from dpdpkit.errors import DpdpkitError
14
+
15
+
16
+ class Unauthenticated(DpdpkitError):
17
+ code = "unauthenticated"
18
+ http_status = 401
19
+
20
+
21
+ class Forbidden(DpdpkitError):
22
+ code = "forbidden"
23
+ http_status = 403
24
+
25
+
26
+ class NotImplementedYet(DpdpkitError):
27
+ code = "not_implemented"
28
+ http_status = 501
29
+
30
+
31
+ class ConsentRequiredHTTP(HTTPException):
32
+ """Raised by :func:`require_consent`. Rendered as the contract's Error body once the kit is installed
33
+ on the app; otherwise FastAPI renders it as ``{"detail": {...}}`` with the same code."""
34
+
35
+ def __init__(self, purpose: str) -> None:
36
+ super().__init__(
37
+ status_code=403,
38
+ detail={
39
+ "code": "consent_required",
40
+ "message": f"consent required for purpose {purpose!r}",
41
+ "details": {"purpose": purpose},
42
+ },
43
+ )
44
+
45
+
46
+ def error_response(status: int, code: str, message: str, details: dict[str, Any] | None = None) -> JSONResponse:
47
+ return JSONResponse(
48
+ status_code=status, content={"error": {"code": code, "message": message, "details": details or {}}}
49
+ )
50
+
51
+
52
+ def render_dpdpkit_error(exc: DpdpkitError) -> JSONResponse:
53
+ status = exc.http_status if exc.http_status != 500 else 400
54
+ return error_response(status, exc.code, exc.message, exc.details)
55
+
56
+
57
+ def render_validation_error(exc: RequestValidationError) -> JSONResponse:
58
+ errors = [
59
+ {"loc": [str(p) for p in e.get("loc", ())], "msg": str(e.get("msg", "")), "type": str(e.get("type", ""))}
60
+ for e in exc.errors()
61
+ ]
62
+ return error_response(422, "invalid_request", "request body or parameters are invalid", {"errors": errors})
63
+
64
+
65
+ async def consent_required_handler(_request: Request, exc: Exception) -> Response:
66
+ assert isinstance(exc, ConsentRequiredHTTP)
67
+ return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
68
+
69
+
70
+ class DpdpRoute(APIRoute):
71
+ """Every dpdpkit endpoint answers errors with ``{"error": {code, message, details}}``."""
72
+
73
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
74
+ handler = super().get_route_handler()
75
+
76
+ async def route(request: Request) -> Response:
77
+ try:
78
+ return await handler(request)
79
+ except DpdpkitError as exc:
80
+ return render_dpdpkit_error(exc)
81
+ except RequestValidationError as exc:
82
+ return render_validation_error(exc)
83
+
84
+ return route
@@ -0,0 +1,34 @@
1
+ """Alembic migrations shipped inside the package. Forward-only; they never drop consent or audit data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from alembic import command
8
+ from alembic.config import Config
9
+ from sqlalchemy import Engine
10
+
11
+ HERE = Path(__file__).parent
12
+ VERSION_TABLE = "dpdp_alembic_version"
13
+
14
+
15
+ def include_name(name: str | None, type_: str, _parent: object) -> bool:
16
+ """dpdpkit shares the host app's database: migrations only ever consider its own tables."""
17
+ if type_ == "table":
18
+ return bool(name) and str(name).startswith("dpdp_") and name != VERSION_TABLE
19
+ return True
20
+
21
+
22
+ def alembic_config(engine: Engine | None = None, url: str | None = None) -> Config:
23
+ cfg = Config()
24
+ cfg.set_main_option("script_location", str(HERE))
25
+ cfg.set_main_option("version_table", VERSION_TABLE)
26
+ if url:
27
+ cfg.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
28
+ if engine is not None:
29
+ cfg.attributes["engine"] = engine
30
+ return cfg
31
+
32
+
33
+ def upgrade(engine: Engine, revision: str = "head") -> None:
34
+ command.upgrade(alembic_config(engine), revision)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from alembic import context
4
+ from sqlalchemy import create_engine
5
+
6
+ from dpdpkit.fastapi.db import Base
7
+ from dpdpkit.fastapi.migrations import include_name
8
+
9
+ config = context.config
10
+ target_metadata = Base.metadata
11
+ version_table = config.get_main_option("version_table") or "dpdp_alembic_version"
12
+
13
+
14
+ def run_offline() -> None:
15
+ context.configure(
16
+ url=config.get_main_option("sqlalchemy.url"),
17
+ target_metadata=target_metadata,
18
+ literal_binds=True,
19
+ version_table=version_table,
20
+ include_name=include_name,
21
+ )
22
+ with context.begin_transaction():
23
+ context.run_migrations()
24
+
25
+
26
+ def run_online() -> None:
27
+ engine = config.attributes.get("engine") or create_engine(str(config.get_main_option("sqlalchemy.url")))
28
+ with engine.connect() as connection:
29
+ context.configure(
30
+ connection=connection,
31
+ target_metadata=target_metadata,
32
+ version_table=version_table,
33
+ include_name=include_name,
34
+ render_as_batch=connection.dialect.name == "sqlite",
35
+ )
36
+ with context.begin_transaction():
37
+ context.run_migrations()
38
+ connection.commit()
39
+
40
+
41
+ if context.is_offline_mode():
42
+ run_offline()
43
+ else:
44
+ run_online()